diff --git a/.changeset/artifact-persistence-options-rename.md b/.changeset/artifact-persistence-options-rename.md new file mode 100644 index 000000000..082dfcd82 --- /dev/null +++ b/.changeset/artifact-persistence-options-rename.md @@ -0,0 +1,28 @@ +--- +'@tanstack/ai-persistence': minor +--- + +The artifact options for `withGenerationPersistence` are now named +`ArtifactPersistenceOptions`. + +They were declared as a second `export interface WithPersistenceOptions`, which +TypeScript merged with the chat middleware's options of the same name. The merge +was invisible but not harmless: `withPersistence(chat, …)` silently accepted +`extractArtifacts` / `storageKey` / `allowInputUrl` / `artifactFetch`, and +`WithGenerationPersistenceOptions` — which extends it — advertised +`snapshotStreaming` / `snapshotIntervalMs`. Every one of those is a no-op on the +other middleware, so autocomplete offered options that did nothing. + +`WithPersistenceOptions` keeps its meaning: the chat middleware's options. +`WithGenerationPersistenceOptions` is unchanged in shape and is still what you +pass to `withGenerationPersistence`, so only code that named the artifact +interface directly needs an edit: + +```diff +-import type { WithPersistenceOptions } from '@tanstack/ai-persistence' +-function artifactOptions(): WithPersistenceOptions { ++import type { ArtifactPersistenceOptions } from '@tanstack/ai-persistence' ++function artifactOptions(): ArtifactPersistenceOptions { + return { storageKey: ({ runId, artifactId }) => `media/${runId}/${artifactId}` } + } +``` diff --git a/.changeset/client-declined-generation-restore.md b/.changeset/client-declined-generation-restore.md new file mode 100644 index 000000000..2c914702a --- /dev/null +++ b/.changeset/client-declined-generation-restore.md @@ -0,0 +1,20 @@ +--- +'@tanstack/ai-client': minor +--- + +A restored generation whose result can't be rebuilt now reports an error instead +of repainting as a blank success. + +Every `reconstructResult` mapper in `generation-reconstruct.ts` (and the video +client's built-in `reconstructVideoResult`) returns `null` when the persisted +record lacks what it needs — most commonly an output artifact stored without a +serve `url`, which is possible because `artifactUrl` is optional server-side. +`repaintFromSnapshot` silently skipped `setResult` in that case, leaving +`status: 'success'` with `result: null`: a state no consumer can render, and one +that hides the real cause. + +When a mapper declines a snapshot whose status is `complete`, the restore now +settles on `status: 'error'` with an explanatory message and fires `onError`. A +decline on any other status is still silent — a `running` snapshot has no result +yet by definition, and the rejoin delivers it. A client with no +`reconstructResult` mapper at all is unaffected. diff --git a/.changeset/client-generation-hydration-errors.md b/.changeset/client-generation-hydration-errors.md new file mode 100644 index 000000000..9aa35d8b0 --- /dev/null +++ b/.changeset/client-generation-hydration-errors.md @@ -0,0 +1,25 @@ +--- +'@tanstack/ai-client': minor +--- + +Server-driven generation hydration no longer swallows every failure. + +`GenerationClient` / `VideoGenerationClient` mount hydration +(`persistence: true`) wrapped the whole `hydrateGeneration` call in a bare +`try { … } catch { return }`, collapsing a transport error, a `403` from the +`reconstructGeneration` authorize gate, an unparseable body, and "no record for +this thread" into one indistinguishable silent no-op — so an app could not tell a +broken server from a fresh thread, and had no signal to retry. + +- A genuine **miss** (the server reports no record) stays silent, as before. +- A genuine **failure** now surfaces on `status` / `error` and fires `onError`, + with a message naming the cause. A record the client's own validator rejects + (unknown schema version, missing/invalid `status` or `resumeState`) counts as a + failure, not a miss. +- The failure is skipped when a `generate()` took ownership of the client while + the hydrate request was in flight — the live run still wins. + +Relatedly, `fetchServerSentEvents` / `fetchHttpStream` `hydrateGeneration` now +only treats a `200` carrying `null` as a miss. Any other non-object body (a +string, an array) rejects instead of being reported as an empty thread, so a +misconfigured route no longer masquerades as a fresh one. diff --git a/.changeset/client-truncated-generation-stream.md b/.changeset/client-truncated-generation-stream.md new file mode 100644 index 000000000..ad5cb9045 --- /dev/null +++ b/.changeset/client-truncated-generation-stream.md @@ -0,0 +1,27 @@ +--- +'@tanstack/ai-client': minor +--- + +A generation stream that ends without a terminal chunk now settles to `error` +instead of wedging the client on `generating` forever. + +`GenerationClient.processStream` / `VideoGenerationClient.processStream` only +settled the status on `RUN_FINISHED` or `RUN_ERROR`. A `for await` loop over a +stream that simply _ends_ — a proxy/load-balancer idle timeout, a server restart +mid-run, or a durable log whose terminal append never landed — returns normally, +so no catch fired and the client came to rest on +`status: 'generating'`, `isLoading: false`, `result: null`, with `onError` never +called. Worse, the resume snapshot stayed `running`, so every subsequent mount +rejoined the same dead run and repeated the same outcome. + +Both clients now throw when the stream ends with no terminal chunk seen (and the +read wasn't aborted by `stop()` / `dispose()`), which routes the failure through +the existing error path: `status: 'error'`, `error` set, `onError` fired, and the +resume snapshot rewritten to a terminal `error` with a null `resumeState` so +nothing chases it again. This applies to both the initial `generate()` path and +the mount-time `rejoinInFlight` path. A rejoin failure now also fires `onError`, +matching `generate()`. + +This is the sibling of the earlier "rejoin settles to error" fix, which covered a +missing and a throwing `joinRun` but not a join that returns cleanly with no +terminal chunk. diff --git a/.changeset/client-typed-storage-adapter-defaults.md b/.changeset/client-typed-storage-adapter-defaults.md new file mode 100644 index 000000000..983ba5753 --- /dev/null +++ b/.changeset/client-typed-storage-adapter-defaults.md @@ -0,0 +1,17 @@ +--- +'@tanstack/ai-client': minor +--- + +`localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` +default their `TValue` back to `ChatPersistedState` instead of `any`. + +The `any` default was justified by a claim that "a bare call works for both the +chat **and generation** `persistence` options with no type argument". That is no +longer true: generation `persistence` is now `boolean` (server-driven only), so +chat is the sole `persistence` option that takes a storage adapter — and the +`any` default erased `getItem` / `setItem` type safety for chat users in exchange +for nothing. + +A bare `localStoragePersistence()` still needs no type argument. Only a +standalone store holding something other than a chat transcript needs the +explicit one, e.g. `localStoragePersistence()`. diff --git a/.changeset/conformance-generation-stores.md b/.changeset/conformance-generation-stores.md new file mode 100644 index 000000000..3ec585050 --- /dev/null +++ b/.changeset/conformance-generation-stores.md @@ -0,0 +1,20 @@ +--- +'@tanstack/ai-persistence': minor +--- + +Extend the shared conformance testkit to the generation stores. + +**Migration — every existing adapter must update its conformance call.** The suite now fails loudly on a store that is absent without being declared, so a chat-only adapter that used to pass unchanged will start failing on `generationRuns` / `artifacts` / `blobs`. Declare them absent: + +```diff +- runPersistenceConformance('my-adapter', () => makePersistence()) ++ runPersistenceConformance('my-adapter', () => makePersistence(), { ++ skip: ['generationRuns', 'artifacts', 'blobs'], ++ }) +``` + +Drop an entry from `skip` as you implement that store — the suite then holds it to the contract below. Declaring absence is deliberate: a silently skipped store is how an adapter ships a `generationRuns` implementation that was never exercised. + +`runPersistenceConformance` now exercises `generationRuns`, `artifacts`, and `blobs` alongside the four chat state stores, so a hand-rolled generation backend is held to the same gate as a chat one: `createOrResume` idempotency and `findLatestForThread` (latest by `startedAt`, thread-scoped, terminal runs included) on the run store; upsert `save`, `list(runId)` ordering, and `delete` / `deleteForRun` scoping on the artifact store; and byte/metadata round-trips, overwrite, silent absent-key `delete`, and `list` prefix + cursor paging on the blob store. Two invariants that were easy to get wrong and are now checked: `list`'s `prefix` matches **literally and case-sensitively** (a SQL backend using `LIKE` fails on both counts, since SQLite's `LIKE` is case-insensitive for ASCII and treats `%` / `_` as wildcards), and cursor paging visits every key exactly once. + +`examples/ts-react-chat`'s self-contained `node:sqlite` adapter implements all seven stores and runs the full suite; its server-side generation route is backed by that adapter, so generated images survive a dev-server restart. diff --git a/.changeset/core-jsdoc-server-fn-example.md b/.changeset/core-jsdoc-server-fn-example.md new file mode 100644 index 000000000..437f0f566 --- /dev/null +++ b/.changeset/core-jsdoc-server-fn-example.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': patch +--- + +Fix `@tanstack/ai` breaking non-React TanStack Start builds. + +A JSDoc example on `replayRunStream` inlined a server-function builder chain. Comments survive into `dist`, and Start's server-fn Vite plugin decides whether a module needs compiling by regex-matching the source — so it treated this package as a server-fn module and tried to resolve the framework's `@tanstack/*-start` package, failing the build of any Solid/Vue/Svelte Start app (`could not resolve "@tanstack/solid-start"`). The example now declares the generator separately and no longer trips the match. diff --git a/.changeset/core-result-transforms-required.md b/.changeset/core-result-transforms-required.md new file mode 100644 index 000000000..ed7e16f29 --- /dev/null +++ b/.changeset/core-result-transforms-required.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': minor +--- + +`GenerationMiddlewareContext.resultTransforms` is now required. + +Middleware registers a result transform by pushing onto the array, so an optional one let a host that builds its own context omit it and silently no-op every registration — generation persistence would then mark a run completed with neither its result nor its artifacts written, with nothing to observe but the missing data. Every context the library builds already comes from `createGenerationContext`, which always sets `[]`, so this only affects code that constructs a `GenerationMiddlewareContext` by hand: set `resultTransforms: []`. diff --git a/.changeset/core-summarize-generation-middleware.md b/.changeset/core-summarize-generation-middleware.md new file mode 100644 index 000000000..63d832dae --- /dev/null +++ b/.changeset/core-summarize-generation-middleware.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai': minor +--- + +`summarize()` accepts generation middleware, so summaries can be persisted. + +`useSummarize({ persistence: true, threadId })` type-checked exactly like the six media hooks, but `summarize()` took no `middleware`, so no library path could ever write its run record and a reload restored nothing. It now takes `middleware` like the `generate*` activities: one `onStart`, the result transforms applied to the `SummarizationResult`, then `onFinish` / `onError`, in both streaming and non-streaming mode (a consumer that disconnects mid-summary fires `onAbort`). In streaming mode the transformed result is what is yielded, so the client and the persisted record hold the same object. + +`GenerationActivity` gained `'summarize'`, and `otelMiddleware` maps it to the `summarize` operation name. Summaries are text, so there are no artifacts: a persistence middleware stores the run record and its result and nothing else. diff --git a/.changeset/core-video-job-run-lifecycle.md b/.changeset/core-video-job-run-lifecycle.md new file mode 100644 index 000000000..327f51cc6 --- /dev/null +++ b/.changeset/core-video-job-run-lifecycle.md @@ -0,0 +1,15 @@ +--- +'@tanstack/ai': minor +--- + +Fix non-streaming `generateVideo()` losing the generation when persistence is on. + +A non-streaming `generateVideo()` call only SUBMITS a job — the video does not exist until a later poll — but it fired `onFinish` as soon as the job was queued and never applied the result transforms, and it never put the caller's `threadId` on the middleware context. With `withGenerationPersistence` that meant `generateVideo({ threadId, middleware })` threw for want of a scope, and (once given one) would have stamped the run `completed` with no result, no url, and no stored bytes, while the eventual result had nowhere to land. + +Submitting a job now OPENS the run and `getVideoJobStatus()` closes it, with the two calls correlated by the provider's **`jobId`** — the one id a poller structurally cannot be missing, since it cannot poll without it. Nothing else has to be threaded through: + +- `generateVideo()` (non-streaming) passes `threadId` and the prompt inputs to middleware, files the run under an id derived from the provider + `jobId`, applies the result transforms to the submission (so the run record captures the `jobId` and stays resumable from a later request or process), and fires **no terminal hook**. +- `getVideoJobStatus()` accepts `threadId` and `middleware`, and recomputes the same run id from `adapter` + `jobId`. On the poll that first observes a terminal job state it resumes that run, applies the result transforms — which is where persistence copies the video into the blob store and rewrites `url` to a durable one, so the returned result and the stored record carry the same urls — and fires `onFinish`, or `onError` when the job (or the url fetch) failed. Intermediate polls invoke nothing. Its result gained `jobId`, `expiresAt`, and `artifacts`; `VideoJobResult` gained `artifacts` (refs for persisted prompt INPUTS, e.g. a start frame). +- `runId` on a non-streaming `generateVideo()` call is **ignored** (it remains the wire run id in stream mode). The run id has to be recomputable by the poll from the `jobId` alone; honoring a custom one would reintroduce the failure this avoids — a caller who set it on the submit and forgot it on the poll would silently open a second record while the first sat unfinished forever. + +Two consequences worth knowing. Because the job id only exists once the provider accepts the job, `onStart` now fires AFTER the submit request, so an `otelMiddleware()` span covers the run from acceptance onward rather than the submit round-trip, and a submission that FAILS (no job to key on) opens and immediately fails a run under the call's `requestId` — terminal and unresumable, but filed under the thread so a hydrating client sees the failure. And `threadId` must reach the poll: omitting it makes generation persistence throw loudly rather than file the finished video where nothing can hydrate it. diff --git a/.changeset/deprecate-generation-id.md b/.changeset/deprecate-generation-id.md new file mode 100644 index 000000000..29e017b19 --- /dev/null +++ b/.changeset/deprecate-generation-id.md @@ -0,0 +1,15 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +--- + +Deprecate generation `id` in favor of `threadId` as the single identity. + +`threadId` is the scope for the wire, devtools, and persistence. When it is +supplied, `id` is typed `never` so you cannot pass both. Legacy `id` remains +only for ephemeral runs that have no `threadId` (wire/devtools fallback) and is +marked `@deprecated`. diff --git a/.changeset/durable-runs-survive-disconnect.md b/.changeset/durable-runs-survive-disconnect.md new file mode 100644 index 000000000..e444ce800 --- /dev/null +++ b/.changeset/durable-runs-survive-disconnect.md @@ -0,0 +1,28 @@ +--- +'@tanstack/ai': patch +--- + +Durable streaming runs now survive a client disconnect (page reload) and can be +tailed to completion by a rejoining client — no route-side detachment code +required. Two internal fixes to `toServerSentEventsResponse` / +`toHttpResponse`, both additive with no public API change: + +- **`RUN_STARTED` is a durability flush boundary.** One-shot generation + activities (image, speech, transcription, summarize) emit `RUN_STARTED`, then + await the provider for seconds, then a terminal. Previously `RUN_STARTED` sat + in the batch buffer, so the durable log was empty for the whole run and a + mount-time `joinRun` fast-failed as "run gone". It now flushes immediately, so + the run is resumable from the instant it starts. +- **The producer is decoupled from the HTTP response when durability is on.** + A client disconnect used to abort the producer and seal the log with + `RUN_ERROR`, even though the run kept running and recorded success. Now, on a + durable (persistence-on) run, a response cancel detaches and the producer + keeps draining into the log to its real terminal, so a rejoining client tails + it to completion. This supersedes the earlier "producers terminalize the log + on cancellation" behavior **for durable runs only**: + - **No durability (persistence off)** → unchanged: a disconnect aborts and + stops the run. + - **Durability present (persistence on)** → the run survives a disconnect. + - A genuine caller stop — aborting an `abortController` you pass (e.g. wired to + `request.signal`, as the resumable-streams demo does) — still terminalizes + the run, so opt-in die-on-disconnect keeps working. diff --git a/.changeset/generate-video-result-transforms.md b/.changeset/generate-video-result-transforms.md new file mode 100644 index 000000000..13ae714d0 --- /dev/null +++ b/.changeset/generate-video-result-transforms.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': patch +--- + +Fix `generateVideo` dropping result transforms and run identity, which made a persisted video restore as nothing. + +Streaming video was the only media activity that never called `applyGenerationResultTransforms`, and never put the caller's `threadId` / `runId` on the middleware context. Because `withGenerationPersistence` registers BOTH its artifact capture and its run-record `result` write as result transforms — pushed onto an optional `ctx.resultTransforms` — both silently no-opped. A completed video therefore stored a run record with `status: 'complete'` and nothing else: no result metadata, no artifact refs, no stored bytes, and no thread link (the run was filed under the internal `requestId`). On reload the client found no output artifact and restored nothing. + +Streaming video now applies the transforms to its terminal result before yielding it, so the `generation:result` chunk and the stored run record carry the same URLs — including the durable app-origin URL that `artifactUrl` stamps. It also passes `threadId`, `runId`, and `artifactInputs` into the middleware context, matching `generateImage`. + +`threadId` is now a documented option on `generateVideo` (it previously had none — callers passing one via an object spread type-checked but were silently ignored). When omitted, an id is still minted for the `RUN_STARTED` / `RUN_FINISHED` wire chunks, but the middleware context gets `undefined` rather than the minted value: a fabricated thread id is a slot no client can hydrate by, which is worse than recording no link at all. diff --git a/.changeset/generation-mount-hydration-and-speech-restore.md b/.changeset/generation-mount-hydration-and-speech-restore.md new file mode 100644 index 000000000..13b7b3e02 --- /dev/null +++ b/.changeset/generation-mount-hydration-and-speech-restore.md @@ -0,0 +1,32 @@ +--- +'@tanstack/ai-client': patch +'@tanstack/ai-react': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-vue': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-angular': patch +--- + +Fix generation mount hydration to run in the commit phase, and restore TTS +results. + +- The `GenerationClient` / `VideoGenerationClient` used to kick off mount + hydration from their constructor. Framework hooks build the client inside + `useMemo`, so that ran in React's render phase, and several clients mounting + together re-fired the hydrate GET on every discarded/speculative render, + flooding the connection pool (`ERR_INSUFFICIENT_RESOURCES`). Hydration now runs once from `mountDevtools` + (the hooks' commit-phase mount effect), guarded by `serverHydrationStarted`. + `initialResumeSnapshot` still seeds SSR/first paint. Note for direct + (non-framework) `GenerationClient`/`VideoGenerationClient` users: mount + hydration and the "missing `hydrateGeneration` handler" warning now fire from + `mountDevtools()` rather than the constructor, so call `mountDevtools()` (as + every framework hook does on mount) to trigger a server/storage restore; + `generate()` still triggers it too. +- New `reconstructSpeechResult` mapper, wired into the speech hook of **every** + framework package — `useGenerateSpeech` (React, Solid, Vue), + `createGenerateSpeech` (Svelte) and `injectGenerateSpeech` (Angular). A + restored `TTSResult` carries no base64 bytes (they live in the blob store), so + it surfaces the durable serve URL through `result.artifacts`; the speech clip + now repaints after a reload instead of showing status only. Previously only + React was wired, so a restored TTS run on the other four repainted + `status`/`error` but left `result` null. diff --git a/.changeset/generation-persistence-server-only.md b/.changeset/generation-persistence-server-only.md new file mode 100644 index 000000000..fa29823c1 --- /dev/null +++ b/.changeset/generation-persistence-server-only.md @@ -0,0 +1,37 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-angular': minor +--- + +Generation persistence is server-driven only. The hooks' `persistence` option is +now a boolean. + +```diff +- useGenerateImage({ threadId, connection, persistence: localStoragePersistence() }) ++ useGenerateImage({ threadId, connection, persistence: true }) +``` + +A generation is one job with one result, not a growing transcript, so a browser +copy of its record bought nothing that the server record does not already +provide, and cost a second source of truth to keep in step. Worse, the two modes +restored differently: a client snapshot can never hold the generated bytes, so +`result` came back `null` from storage but whole from the server. One mode +removes that split. + +Gone from `@tanstack/ai-client`: the `GenerationPersistence` type, the storage +read/write path in `GenerationClient` and `VideoGenerationClient`, and the +adapter arm of `GenerationPersistenceOption`. `persistence: true` still requires +a stable `threadId` at the type level, and still needs a `hydrateGeneration` +handler (every built-in connection has one) plus a `reconstructGeneration` route. + +`initialResumeSnapshot` is unchanged, so an app that wants to manage its own +storage can still seed the client from it. + +**None of this touches chat.** `useChat` keeps both modes, and +`localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` +are still exported and still work for conversations. diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md new file mode 100644 index 000000000..68af8bcab --- /dev/null +++ b/.changeset/generation-persistence.md @@ -0,0 +1,30 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-utils': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +'@tanstack/ai-event-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +--- + +Add generation persistence, mirroring chat: media generation runs survive a reload or dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes. + +**Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` the run's scope — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. + +**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. `authorize` is optional at the type level for single-user and prototype routes, but any multi-user deployment must pass it: the run and thread ids arrive from the caller, so identity has to be derived from server-side session state and ownership checked before the helper reads persistence. The same applies to a route that serves artifact bytes by id. + +**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. + +**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take a `persistence` option, and it is boolean — server-driven only, with no client-storage adapter arm: `true` hydrates the last run for a stable `threadId` on mount, and the browser caches nothing. Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and reports the in-flight run's id as `runId` — there is no `resumeSnapshot` / `resumeState` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. With byte storage configured, a restored `result` is rebuilt whole, its media resolved to the durable serve URL and its refs on `result.artifacts`; without it, `status` / `error` restore and `result` stays null. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. + +**`threadId` is required whenever `persistence` is set**, enforced at the type level. It is the generation's _scope_ — a stable, app-chosen name for the slot successive runs fill (`product-123-hero`, `video-9-start-frame`) — not a link to a chat conversation, so a workflow generating media outside any conversation names it just as naturally. It stays optional for ephemeral generations, so existing call sites that do not opt into persistence are unaffected. Persistence keys on `threadId` and nothing else; the legacy `id` is deprecated and typed `never` whenever `threadId` is supplied — pass one scope, not two. Previously the key fell back to `id` and then to a generated id, which silently wrote a different slot on every reload — restoring nothing while orphaning the last record. + +**Choose where bytes land.** `withGenerationPersistence`'s new `storageKey` option maps each artifact to its blob-store key, so generated media can live in your own folder structure instead of the default `artifacts//`. Server-side only — a browser-supplied key would be a path-traversal and cross-tenant-write vector. The resolved key is recorded on the new `ArtifactRecord.blobKey` (it is no longer derivable once arbitrary) and reads resolve through `resolveArtifactBlobKey`; records written before the field existed fall back to the default convention, so it is a non-breaking addition. + +`findLatestForThread` is a **required** method on `GenerationRunStore` — a `?threadId=` lookup is the whole mount-time hydration path, so a store that cannot answer it cannot back generation persistence. TypeScript rejects a store that omits it; a JavaScript adapter that ships without it fails at the call, not silently. + +Snapshots arriving from the server are validated with the new `parseGenerationResumeSnapshot` before anything is repainted. diff --git a/.changeset/generation-rejoin-settles-to-error.md b/.changeset/generation-rejoin-settles-to-error.md new file mode 100644 index 000000000..ad1c9d8dd --- /dev/null +++ b/.changeset/generation-rejoin-settles-to-error.md @@ -0,0 +1,19 @@ +--- +'@tanstack/ai-client': patch +--- + +A generation mount-time rejoin that can't finish now settles to `error` instead +of hanging on `generating`. + +- `recordResumeSnapshotError` surfaces `error` on the observable `status` even + when a streamed `RUN_ERROR` already flipped the resume snapshot to `error` + (via `observeResumeSnapshot`). Previously its early-return skipped + `setStatus`, so a rejoin whose delivery log had aged out (or whose route + couldn't serve the join) left the hook stuck on `generating` forever. Guarded + so the live `generate()` path doesn't double-emit `error`. +- `GenerationClient` / `VideoGenerationClient` `dispose()` no longer calls + `stop()`: a teardown (unmount / React StrictMode dispose) must not mark the + run non-resumable and wipe the `running` snapshot the way a user-driven + `stop()` intentionally does — that destroyed the resume state so a remount + could never rejoin. It now aborts only the in-flight delivery, keeps + the snapshot resumable, and re-arms mount hydration so a remount rejoins. diff --git a/.changeset/generation-run-status-matches-run-status.md b/.changeset/generation-run-status-matches-run-status.md new file mode 100644 index 000000000..586a1db7d --- /dev/null +++ b/.changeset/generation-run-status-matches-run-status.md @@ -0,0 +1,36 @@ +--- +'@tanstack/ai-persistence': minor +--- + +`GenerationRunStatus` now uses the same vocabulary as chat's `RunStatus`. + +```diff +- type GenerationRunStatus = 'running' | 'complete' | 'error' | 'interrupted' ++ type GenerationRunStatus = RunStatus // 'running' | 'completed' | 'failed' | 'interrupted' +``` + +The two enums described the same four lifecycle states under different names, +`complete` against `completed` and `error` against `failed`, for no reason +either one could point at. An adapter storing both kinds of run had to keep two +status vocabularies straight, and a shared `status` column needed two sets of +checks. They are now one type, so one column and one check constraint cover both +tables. + +If you wrote a `GenerationRunStore` against the old names, update the two +literals your store maps or validates. `running` and `interrupted` are +unchanged. The conformance suite round-trips both new literals — it writes +`completed` and then `failed` through `update` and reads each back through +`get` — so re-running it against your adapter will catch anything missed. + +The client-facing resume-snapshot status is **unchanged** +(`idle | running | complete | error`). It is a separate vocabulary with its own +`idle` state, mapped from the store status by `reconstructGeneration`, exactly +as chat maps `RunStatus` to `ChatClientState`. Nothing on the wire moves. + +Also corrected: `GenerationRunRecord.threadId` was documented as an "optional +link to the chat conversation that triggered this generation", and typed +optional to match. It is the slot the run fills, the stable app-chosen key +`findLatestForThread` hydrates by, and `withGenerationPersistence` refuses to +start a run without one — so the field is now **required**. A record written +without a scope could never be found again, which is not a shape worth keeping +representable. diff --git a/.changeset/generation-run-threadid-required.md b/.changeset/generation-run-threadid-required.md new file mode 100644 index 000000000..41956cafe --- /dev/null +++ b/.changeset/generation-run-threadid-required.md @@ -0,0 +1,42 @@ +--- +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +--- + +`GenerationRunRecord.threadId` is now required. + +```diff + interface GenerationRunRecord { + runId: string +- threadId?: string ++ threadId: string + … + } +``` + +`GenerationRunStore.createOrResume` requires it on its input, and the +`resumeState` cursor on the hydration payload (`ReconstructedGeneration`, +`GenerationHydrationResult`) narrows from `{ threadId?: string; runId: string }` +to `{ threadId: string; runId: string }`. + +**Why.** The optional field described a record no code path could produce and no +client would accept. `withGenerationPersistence` already refused to start a run +without a scope, so every record the library writes has one. +`findLatestForThread` — the only query that hydrates a generation — keys on it, +so a record without one could be written and then never read back. And the +client discarded any snapshot that arrived without one. + +That last disagreement was a silent failure: the server legitimately omitted +`threadId` for a record that had none, and `parseGenerationResumeSnapshot` +responded by dropping the **entire** snapshot — status, result and error along +with the cursor — leaving a blank idle panel with no diagnostic while the +provider kept billing. Making the field required removes the disagreement by +construction rather than patching one side of it. + +**Migration.** If you wrote a `GenerationRunStore`, make the column non-nullable +and stop defaulting the field to `null`/`undefined`. The conformance suite now +asserts `threadId` round-trips exactly and is not mutated by an idempotent +`createOrResume`, so re-running it against your adapter will catch anything +missed. Records already stored without a `threadId` were unreachable by +`findLatestForThread`, so there is nothing to backfill for hydration to work — +delete them or assign them a scope. diff --git a/.changeset/generation-threadid-from-the-activity.md b/.changeset/generation-threadid-from-the-activity.md new file mode 100644 index 000000000..2d6af4e4c --- /dev/null +++ b/.changeset/generation-threadid-from-the-activity.md @@ -0,0 +1,38 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-persistence': minor +--- + +`withGenerationPersistence` reads `threadId` from the activity instead of +requiring it twice. + +```diff +- generateImage({ threadId, middleware: [withGenerationPersistence(p, { threadId })] }) ++ generateImage({ threadId, middleware: [withGenerationPersistence(p)] }) +``` + +**The bug underneath (`@tanstack/ai`).** Four streaming activities spread the +resolved wire identity over their own options: + +```diff +- (resolved) => runGenerateImage({ ...options, ...resolved }) ++ (resolved) => runGenerateImage({ ...options, runId: resolved.runId }) +``` + +`streamGenerationResult` mints a thread id for the `RUN_*` chunks when the caller +passes none, so that spread overwrote the caller's `threadId` with an id known to +nobody. `generateImage`, `generateAudio`, `generateSpeech`, and +`generateTranscription` were all affected; `generateVideo` already did this +correctly. Any middleware reading `ctx.threadId` on those four saw a fabricated +value it could not tell apart from a real one, which is why persistence ignored +the context and demanded the option. + +**The option (`@tanstack/ai-persistence`).** `WithGenerationPersistenceOptions.threadId` +is now optional, and an override rather than the only source. The scope resolves +to `opts.threadId ?? ctx.threadId`, and a run with neither throws a named error +at `onStart` instead of being filed somewhere nothing can hydrate it from. Code +that passes `threadId` to both keeps working unchanged. + +The redundancy was also a trap: passing different values to the activity and the +middleware silently split one slot in two, with the wire using one id and the +record filed under the other. diff --git a/.changeset/hooks-angular-chat-option-parity.md b/.changeset/hooks-angular-chat-option-parity.md new file mode 100644 index 000000000..aecd3becf --- /dev/null +++ b/.changeset/hooks-angular-chat-option-parity.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai-angular': patch +--- + +`InjectChatOptions` no longer exposes `onResumeStateChange`. + +`injectChat` surfaces the run identity as the `runId` signal and pending +interrupts through `interrupts` / `pendingInterrupts` / `onInterruptStateChange`, +exactly like React / Solid / Vue / Svelte / Preact — but Angular's omit list was +missing the key, so `onResumeStateChange` leaked as a public option and +`injectChat` forwarded to it. Both the key and the forwarding are gone; a caller +passing it now gets a type error instead of depending on an option no other +framework offers. diff --git a/.changeset/hooks-expose-run-id.md b/.changeset/hooks-expose-run-id.md new file mode 100644 index 000000000..425efb437 --- /dev/null +++ b/.changeset/hooks-expose-run-id.md @@ -0,0 +1,47 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-angular': minor +--- + +**Breaking:** the hooks expose `runId` instead of `resumeState`. + +```diff +- const { resumeState } = useChat({ threadId, connection }) +- const liveRunId = resumeState?.runId ?? null ++ const { runId } = useChat({ threadId, connection }) +``` + +Every chat hook (`useChat` / `createChat` / `injectChat`) and every generation +hook (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, +`useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription` and the +Solid / Vue / Svelte / Angular equivalents) now returns `runId: string | null` — +the id of the run streaming right now, or `null` when nothing is in flight. + +`resumeState` was a `{ threadId, runId }` pair whose `threadId` half was always +the id the caller had just passed in, so the only new information it carried was +the run id, wrapped in an object that had to be unwrapped and null-checked. +`runId` is the thing callers actually reach for: the handle you send to your own +endpoint to cancel or poll a provider job, since `stop()` only aborts the local +stream and does not stop work already running on the provider. + +On chat it also reports **more** than `resumeState` did. `resumeState` only ever +held a run that was interrupted or being rejoined, so it stayed `null` through an +ordinary streaming turn. `runId` tracks every run: it is set when any run starts +(including a rejoin) and cleared when it settles, backed by the new +`ChatClient.getCurrentRunId()`. + +`injectChat` (Angular) exposed no equivalent field before and now returns `runId` +alongside the other frameworks. + +`ChatResumeState` and `GenerationResumeState` remain exported — they still +describe the persisted resume snapshot (and `resumeInterruptsUnsafe` still takes +a `ChatResumeState`). They are simply no longer part of a hook's return shape. + +New docs page: [Id map](https://tanstack.com/ai/latest/docs/persistence/id-map) +covers what each id means on chat versus generation, how to choose a `threadId`, +and when to read `runId`. diff --git a/.changeset/hooks-generation-persistence-docs.md b/.changeset/hooks-generation-persistence-docs.md new file mode 100644 index 000000000..2b35b7a74 --- /dev/null +++ b/.changeset/hooks-generation-persistence-docs.md @@ -0,0 +1,18 @@ +--- +'@tanstack/ai-react': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-vue': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-angular': patch +--- + +Correct the `persistence` / `threadId` JSDoc on every generation hook. + +`persistence` is now `boolean`, but the tooltip still described a third value — +"a storage adapter: client-driven — the lightweight snapshot is cached under +`generation:`" — left over from the deleted client-side persistence +surface, and `threadId` still claimed persistence keys on it "in **both** +modes". IDE tooltips on a public option were telling users to pass something +the types reject. Both now describe the server-driven-only behaviour, and +`threadId` documents that the persisted record requires it (as does +`withGenerationPersistence` on the server). diff --git a/.changeset/openai-reasoning-sampling.md b/.changeset/openai-reasoning-sampling.md new file mode 100644 index 000000000..e98ef6d0d --- /dev/null +++ b/.changeset/openai-reasoning-sampling.md @@ -0,0 +1,14 @@ +--- +'@tanstack/ai-openai': patch +--- + +Drop `temperature` / `top_p` for OpenAI reasoning models so they don't 400. + +The o-series and the GPT-5 reasoning family reject `temperature`/`top_p` +(`400 Unsupported parameter`), but a caller — or the summarize adapter's +low-temperature default — has no way to know a given model does. The OpenAI text +adapter now strips both for reasoning models (matched by +`openAIModelRejectsSamplingParams`, which covers `o*` and non-`*-chat-latest` +`gpt-5*` plus `codex-mini-latest`). Stripping only ever averts a guaranteed 400, +so it never changes an otherwise-valid request. This fixes `summarize` (and chat) +on `gpt-5.5` and other reasoning models. diff --git a/.changeset/server-function-generation-persistence.md b/.changeset/server-function-generation-persistence.md new file mode 100644 index 000000000..0ff74bcc6 --- /dev/null +++ b/.changeset/server-function-generation-persistence.md @@ -0,0 +1,22 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +--- + +Make generation persistence work with server functions and direct connections. Server-driven restore (`persistence: true`) previously only worked with the HTTP adapters (`fetchServerSentEvents` / `fetchHttpStream` and their XHR variants), because they are the only connections that implement the optional `hydrateGeneration(threadId)` and `joinRun(runId)` handlers; with `stream()`, `rpcStream()`, or a plain `fetcher` the option silently no-opped, and a stored snapshot still `running` after a reload left the hook stuck on `generating` forever. + +**Handlers on the lightweight adapters (`@tanstack/ai-client`).** `stream()` and `rpcStream()` take an optional second argument, `StreamConnectionHandlers` (`{ hydrate, hydrateGeneration, joinRun }`), spread onto the returned adapter so server-driven persistence works without an HTTP endpoint — each handler is typically a one-line server-function or RPC call. `ConnectConnectionAdapter` also declares the optional chat `hydrate` handler alongside the generation ones. + +**Handlers as generation options (`@tanstack/ai-client`).** `GenerationClientOptions` (and `VideoGenerationClientOptions`, plus every framework hook's generation options) accept optional `hydrateGeneration` / `joinRun` alongside a `fetcher` — or as a fallback when a connection doesn't carry its own. `persistence: true` now hydrates whenever either source exists; the constructor warning only fires when neither does. + +**Interrupted runs no longer stick on `generating` (`@tanstack/ai-client`).** A restored or hydrated snapshot with `status: 'running'` that no `joinRun` handler can tail is repainted as an interrupted error — an interrupted generation cannot be resumed, only re-run — in both `GenerationClient` and `VideoGenerationClient`. + +**Request-free hydration (`@tanstack/ai-persistence`).** New `getGenerationHydration(persistence, id, { by?: 'threadId' | 'runId' })` returns the plain `{ resumeSnapshot, activeRun }` payload straight from the `generationRuns` store, so a server function can back `hydrateGeneration` without fabricating a `Request`. `reconstructGeneration` now delegates to it; `authorize` stays on the `Request`-based function only, so server-function callers gate on their own session before resolving the id. + +**Server-function run replay (`@tanstack/ai`).** `memoryStream` also accepts an explicit `{ runId, offset? }` init instead of a `Request`, and a new `replayRunStream(durability, offset?)` async generator maps a durability `read` (from the start by default) to a bare `StreamChunk` stream — together they let a streaming server function serve `joinRun` for a run id it received as call data. diff --git a/.changeset/summarize-resumable.md b/.changeset/summarize-resumable.md new file mode 100644 index 000000000..2f82f3361 --- /dev/null +++ b/.changeset/summarize-resumable.md @@ -0,0 +1,17 @@ +--- +'@tanstack/ai': patch +'@tanstack/openai-base': patch +--- + +Make streaming `summarize()` resumable across a mid-run reload, like the media +activities. Additive, no public API change beyond two optional fields: + +- `summarize()` (and `SummarizationOptions`) accept optional `runId` / `threadId`. + When set on a streaming summarize, they are threaded into the wrapped chat so + the emitted `RUN_STARTED` carries the caller's `runId` — letting a + delivery-durable route key the run's log by the same id the client rejoins + with, so a mount-time `joinRun` tails the run to completion instead of + fast-failing on a mismatched (empty) log. +- `@tanstack/openai-base`'s Responses `chatStream` now honors + `options.runId` for the AG-UI `RUN_STARTED` (mirroring how it already honors + `options.threadId`), falling back to a generated id when unset. diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index d275ad260..776701950 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -216,14 +216,14 @@ console.log(result.summary); ## Image Generation -Generate images with DALL-E: +Generate images: ```typescript import { generateImage } from "@tanstack/ai"; import { openaiImage } from "@tanstack/ai-openai"; const result = await generateImage({ - adapter: openaiImage("gpt-image-1"), + adapter: openaiImage("gpt-image-2"), prompt: "A futuristic cityscape at sunset", numberOfImages: 1, size: "1024x1024", @@ -239,7 +239,7 @@ import { generateImage } from "@tanstack/ai"; import { openaiImage } from "@tanstack/ai-openai"; const result = await generateImage({ - adapter: openaiImage("gpt-image-1"), + adapter: openaiImage("gpt-image-2"), prompt: "...", modelOptions: { quality: "high", // "high" | "medium" | "low" | "auto" diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index 9f6b78ee4..3bdc1513b 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -230,8 +230,9 @@ Each media call produces one `CLIENT` span tagged with the activity's `gen_ai.op | `generateAudio` | `audio_generation` | | `generateSpeech` | `text_to_speech` | | `generateTranscription` | `transcription` | +| `summarize` | `summarize` | -The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including `tanstack.ai.usage.units_billed` for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle; for non-streaming `generateVideo` it covers job submission. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked. +The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including `tanstack.ai.usage.units_billed` for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle. Non-streaming video is two calls, so the submit itself emits no span — the run opens once the provider accepts the job, and the `getVideoJobStatus()` poll that observes a terminal state ends it. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked. `otelMiddleware` applies the same `spanNameFormatter`, `attributeEnricher`, `onBeforeSpanStart`, and `onSpanEnd` extension points to media spans — the span info is discriminated by `kind`, where media spans report `kind: 'generation'`. For a custom backend, implement the base `GenerationMiddleware` contract directly; its hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) receive the `GenerationMiddlewareContext` and fire for every activity, chat included. The `GenerationMiddleware` types are exported from the package root, while the `otelMiddleware` value lives on the `@tanstack/ai/middlewares/otel` subpath so importing `@tanstack/ai` never requires the optional `@opentelemetry/api` peer. diff --git a/docs/advanced/runtime-adapter-switching.md b/docs/advanced/runtime-adapter-switching.md index cc63a2e2c..e5c6189c9 100644 --- a/docs/advanced/runtime-adapter-switching.md +++ b/docs/advanced/runtime-adapter-switching.md @@ -126,7 +126,7 @@ import { geminiImage } from '@tanstack/ai-gemini' type ImageProvider = 'openai' | 'gemini' const imageAdapters: Record ReturnType> = { - openai: () => openaiImage('gpt-image-1'), + openai: () => openaiImage('gpt-image-2'), gemini: () => geminiImage('gemini-3.1-flash-image-preview'), } diff --git a/docs/advanced/typed-options.md b/docs/advanced/typed-options.md index ad2ef65f3..cafa8a904 100644 --- a/docs/advanced/typed-options.md +++ b/docs/advanced/typed-options.md @@ -139,7 +139,7 @@ import { createImageOptions, generateImage } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' const heroImageOptions = createImageOptions({ - adapter: openaiImage('gpt-image-1'), + adapter: openaiImage('gpt-image-2'), prompt: 'A glass sphere refracting a sunset over a calm sea', size: '1536x1024', numberOfImages: 1, diff --git a/docs/api/ai-angular.md b/docs/api/ai-angular.md index 291ad2cc5..2ae10c658 100644 --- a/docs/api/ai-angular.md +++ b/docs/api/ai-angular.md @@ -382,7 +382,7 @@ export class CustomGenerationComponent { **Options:** `connection?`, `fetcher?`, `id?`, `body?` (reactive), `devtools?`, `onResult?`, `onError?`, `onProgress?`, `onChunk?` -**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset` — all reactive state is a read-only `Signal`. +**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, `runId`. All reactive state is a read-only `Signal`. ### `injectGenerateImage(options)` diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 8e7768b87..179221594 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -299,7 +299,7 @@ const gen = createGeneration({ **Options:** `connection?`, `fetcher?`, `id?`, `body?`, `onResult?`, `onError?`, `onProgress?`, `onChunk?` -**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, `updateBody` -- all state properties are reactive getters. +**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, `runId`, `updateBody` -- all state properties are reactive getters. ### `createGenerateImage(options)` diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index 06542576d..7358000cb 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -316,7 +316,7 @@ const { generate, result, isLoading, error, status, stop, reset } = **Options:** `connection?`, `fetcher?`, `id?`, `body?`, `onResult?`, `onError?`, `onProgress?`, `onChunk?` -**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset` -- all reactive state is `DeepReadonly>`. +**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, `runId` -- all reactive state is `DeepReadonly>`. ### `useGenerateImage(options)` diff --git a/docs/chat/connection-adapters.md b/docs/chat/connection-adapters.md index 96ff035f9..2c3009ec7 100644 --- a/docs/chat/connection-adapters.md +++ b/docs/chat/connection-adapters.md @@ -164,9 +164,11 @@ const httpUrl = `${baseUrl}/chat/http` const sseUrl = `${baseUrl}/chat/sse` ``` -Use the URL your runtime can reach. iOS simulators can often use `localhost` or -`127.0.0.1`, Android emulators commonly use `10.0.2.2` to reach the host -machine, and physical devices need a LAN or tunneled URL. +Use the URL your runtime can reach: + +- **iOS simulator**: often `localhost` or `127.0.0.1`. +- **Android emulator**: commonly `10.0.2.2` to reach the host machine. +- **A physical device**: a LAN or tunneled URL. Prefer `xhrHttpStream()` for Expo and React Native. It pairs with `toHttpResponse()` and reads newline-delimited JSON through incremental XHR @@ -246,6 +248,14 @@ The factory receives the conversation messages plus any per-request `data` you p > **Tip:** `stream()` is **request-scoped**. The factory is invoked once per `sendMessage`, the iterable runs to completion, and the connection closes. If you need a single long-lived channel that multiplexes many sends — for example a WebSocket — use [`subscribe` / `send`](#persistent-transports-websockets-and-friends) instead. +`stream()` also takes an optional second argument of persistence handlers, spread onto the adapter, so server-driven persistence (`persistence: true`) works without an HTTP endpoint. Each is typically a one-line call into your server: + +- `hydrate`: restores a chat thread. +- `hydrateGeneration`: restores a generation's last run. +- `joinRun`: replays a run still in flight. + +See [Generation Persistence](../persistence/generation-persistence#server-functions--direct) for the full server-function wiring. + ## Server Functions via `fetcher` When you call into your server with an **async** function — the universal case for a [TanStack Start](https://tanstack.com/start) server function, which always returns a `Promise` — use the top-level `fetcher` option instead of a connection adapter. `fetcher` is a sibling of `connection` (provide exactly one), and it accepts a plain async function. It mirrors the `fetcher` option on the [generation hooks](../media/generation-hooks). The most common shape is a handler that ends with `toServerSentEventsResponse(...)` and resolves to a `Response`: @@ -275,7 +285,14 @@ const { messages, sendMessage } = useChat({ }); ``` -The fetcher receives `{ messages, data, threadId, runId }` plus an `AbortSignal` (triggered by `stop()` or when a send is superseded). Return a `Response` — whose SSE body the chat client parses for you — **or** an `AsyncIterable`, which is yielded directly. If your server function returns the stream itself (instead of wrapping it in a `Response`), the fetcher handles that too. Sync and `Promise`-wrapped returns are both accepted. +The fetcher receives `{ messages, data, threadId, runId }` plus an `AbortSignal` (triggered by `stop()` or when a send is superseded). Return either: + +- a `Response`: the chat client parses its SSE body for you. +- an `AsyncIterable`: yielded directly. This covers a server function that returns the stream itself rather than wrapping it in a `Response`. + +Sync and `Promise`-wrapped returns are both accepted. + +> **Tip:** The generation hooks (`useGenerateImage` and siblings) take the same server-function shape a step further: alongside their `fetcher` they accept `hydrateGeneration` and `joinRun` options, so `persistence: true` hydrates and rejoins through server functions with no HTTP route at all. See [Generation Persistence — Server functions / direct](../persistence/generation-persistence#server-functions--direct). > **Tip:** The choice between `fetcher` and [`stream()`](#server-functions-and-direct-async-iterables) is about **async vs sync**, not `Response`-vs-iterable — both can yield an `AsyncIterable`. `stream()`'s factory must return that iterable **synchronously**, so a server-function call (which returns a `Promise`) won't typecheck there — that's the gap `fetcher` fills ([issue #509](https://github.com/TanStack/ai/issues/509)). Use `stream()` when you can hand back an async iterable synchronously (in-process `chat()`, an RPC client, tests); use `fetcher` for anything you have to `await`. Both normalize to the same request-scoped adapter, so `stop()`/abort, error handling, and tool calls behave identically. @@ -295,6 +312,8 @@ const { messages } = useChat({ }); ``` +Like `stream()`, `rpcStream()` takes an optional second argument of persistence handlers (`{ hydrate, hydrateGeneration, joinRun }`) so server-driven persistence works over RPC — each handler is usually a one-line RPC call. + ## Persistent Transports (WebSockets and Friends) A persistent transport — WebSocket, BroadcastChannel, postMessage between iframes, a shared worker — is fundamentally different from request/response. You open the channel **once**, then send and receive over it for the lifetime of the client. `stream()`/`connect()` can't model this cleanly because they assume one async iterable per request. @@ -483,7 +502,12 @@ const myAdapter: ConnectConnectionAdapter = { const { messages } = useChat({ connection: myAdapter }); ``` -`runContext` carries `threadId`, `runId`, `clientTools`, and `forwardedProps`. Include them in your request payload so the server can build an AG-UI-compliant response. If your `connect` stream completes without emitting `RUN_FINISHED`, the runtime synthesizes one for you; if it throws, a `RUN_ERROR` is synthesized. +`runContext` carries `threadId`, `runId`, `clientTools`, and `forwardedProps`. Include them in your request payload so the server can build an AG-UI-compliant response. + +The runtime covers the terminal event either way: + +- Your `connect` stream completes without emitting `RUN_FINISHED`: one is synthesized for you. +- Your `connect` stream throws: a `RUN_ERROR` is synthesized. ## The Adapter Interface @@ -525,7 +549,10 @@ export type ConnectionAdapter = | SubscribeConnectionAdapter; ``` -Internally, `ChatClient` normalizes both shapes to a single `subscribe`/`send` pair via `normalizeConnectionAdapter()`. If you provide `connect`, it gets wrapped in an async queue; if you provide `subscribe` + `send` natively, they're used as-is. +Internally, `ChatClient` normalizes both shapes to a single `subscribe`/`send` pair via `normalizeConnectionAdapter()`: + +- Provide `connect` and it gets wrapped in an async queue. +- Provide `subscribe` + `send` natively and they are used as-is. ## Authentication diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index b192a1bd7..e04a75999 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -88,6 +88,48 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) > **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content). +### Threads and runs + +Two ids frame every stream, and they come from the AG-UI protocol itself — not +from any storage layer: + +- A **thread** (`threadId`) is the conversation: the stable identity across + every exchange, reload, and device. +- A **run** (`runId`) is one execution inside it: everything between one + `RUN_STARTED` and its `RUN_FINISHED` (or `RUN_ERROR`). Every start mints a + fresh run id, so a thread accumulates many runs over its life. + +A run is not limited to a single model response. Tool calls and their +follow-up responses stream inside the same run — the whole +[agentic cycle](./agentic-cycle), however many loops it takes, is one run: + +```mermaid +flowchart LR + subgraph thread ["Thread — threadId (stable)"] + direction LR + subgraph r1 ["Run r1 — finished"] + direction TB + e1["RUN_STARTED → text → tool call → tool result → final text → RUN_FINISHED"] + end + subgraph r2 ["Run r2 — finished"] + direction TB + e2["RUN_STARTED → text → RUN_FINISHED"] + end + subgraph r3 ["Run r3 — running"] + direction TB + e3["RUN_STARTED → text"] + end + r1 --> r2 --> r3 + end +``` + +Because run ids are ephemeral, anything long-lived anchors on the thread: +[resumable streams](../resumable-streams/overview) log delivery per `runId`, +while [server persistence](../persistence/chat-persistence#threads-runs-and-turns) +stores the transcript per `threadId`. The media generation hooks take a +`threadId` too, where it names a slot rather than a conversation. See +[Id map](../persistence/id-map). + ### Type-Safe Tool Call Events When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. Prefer the AG-UI field `toolCallName` (or the deprecated `toolName` alias) — both narrow to the union of your tool name literals. The `input` field on `TOOL_CALL_END` is typed as the union of your tool input schemas (typically set on the adapter-emitted END once arguments are complete): @@ -294,7 +336,11 @@ export async function POST(request: Request) { ## Queueing Messages -By default, calling `sendMessage` while a stream is already in flight **queues** the message instead of dropping it — it sends automatically once the current run settles **successfully**. Configure this with the `queue` option, which accepts a `QueueConfig` object, a plain shorthand string, or a strategy function: +By default, calling `sendMessage` while a stream is already in flight **queues** the message instead of dropping it. It sends automatically once the current run settles **successfully**. Configure this with the `queue` option, which accepts any of three forms: + +- a `QueueConfig` object +- a plain shorthand string +- a strategy function ```tsx group=queueing-messages import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; diff --git a/docs/config.json b/docs/config.json index 85d704248..6533d57ae 100644 --- a/docs/config.json +++ b/docs/config.json @@ -164,13 +164,13 @@ "label": "Streaming", "to": "chat/streaming", "addedAt": "2026-04-15", - "updatedAt": "2026-07-17" + "updatedAt": "2026-07-30" }, { "label": "Connection Adapters", "to": "chat/connection-adapters", "addedAt": "2026-04-15", - "updatedAt": "2026-07-17" + "updatedAt": "2026-07-30" }, { "label": "Thinking & Reasoning", @@ -186,7 +186,7 @@ "label": "Overview", "to": "interrupts/overview", "addedAt": "2026-07-16", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-29" }, { "label": "Tool Approval", @@ -221,12 +221,13 @@ "label": "Overview", "to": "resumable-streams/overview", "addedAt": "2026-07-17", - "updatedAt": "2026-07-23" + "updatedAt": "2026-07-29" }, { "label": "Advanced", "to": "resumable-streams/advanced", - "addedAt": "2026-07-17" + "addedAt": "2026-07-17", + "updatedAt": "2026-07-29" }, { "label": "Custom Durability Adapter", @@ -242,19 +243,36 @@ "label": "Overview", "to": "persistence/overview", "addedAt": "2026-07-22", - "updatedAt": "2026-07-27" + "updatedAt": "2026-07-30" + }, + { + "label": "Id Map", + "to": "persistence/id-map", + "addedAt": "2026-07-30" }, { "label": "Chat Persistence", "to": "persistence/chat-persistence", "addedAt": "2026-07-22", - "updatedAt": "2026-07-26" + "updatedAt": "2026-07-30" }, { "label": "Client Persistence", "to": "persistence/client-persistence", "addedAt": "2026-07-22", - "updatedAt": "2026-07-27" + "updatedAt": "2026-07-30" + }, + { + "label": "Generation Persistence", + "to": "persistence/generation-persistence", + "addedAt": "2026-07-28", + "updatedAt": "2026-07-31" + }, + { + "label": "Keep Generated Files", + "to": "persistence/keep-generated-files", + "addedAt": "2026-07-28", + "updatedAt": "2026-07-31" }, { "label": "Controls", @@ -266,7 +284,7 @@ "label": "Build Your Own Adapter", "to": "persistence/build-your-own-adapter", "addedAt": "2026-07-24", - "updatedAt": "2026-07-28" + "updatedAt": "2026-07-31" }, { "label": "Migrations", @@ -278,7 +296,7 @@ "label": "Internals", "to": "persistence/internals", "addedAt": "2026-07-22", - "updatedAt": "2026-07-25" + "updatedAt": "2026-07-30" } ] }, @@ -377,7 +395,7 @@ "label": "Transcription", "to": "media/transcription", "addedAt": "2026-04-15", - "updatedAt": "2026-07-03" + "updatedAt": "2026-07-30" }, { "label": "Audio Recording", @@ -389,25 +407,25 @@ "label": "Audio Generation", "to": "media/audio-generation", "addedAt": "2026-04-23", - "updatedAt": "2026-06-08" + "updatedAt": "2026-07-28" }, { "label": "Image Generation", "to": "media/image-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-07" + "updatedAt": "2026-07-30" }, { "label": "Video Generation", "to": "media/video-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-02" + "updatedAt": "2026-07-30" }, { "label": "Generation Hooks", "to": "media/generation-hooks", "addedAt": "2026-04-15", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-30" } ] }, @@ -435,7 +453,7 @@ "label": "OpenTelemetry", "to": "advanced/otel", "addedAt": "2026-05-08", - "updatedAt": "2026-06-17" + "updatedAt": "2026-07-31" } ] }, @@ -661,19 +679,19 @@ "label": "@tanstack/ai-vue", "to": "api/ai-vue", "addedAt": "2026-04-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-30" }, { "label": "@tanstack/ai-svelte", "to": "api/ai-svelte", "addedAt": "2026-04-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-30" }, { "label": "@tanstack/ai-angular", "to": "api/ai-angular", "addedAt": "2026-06-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-30" } ] }, diff --git a/docs/interrupts/overview.md b/docs/interrupts/overview.md index 62b6fd595..9a5233b10 100644 --- a/docs/interrupts/overview.md +++ b/docs/interrupts/overview.md @@ -30,6 +30,24 @@ picks up exactly where it left off once you answer. 4. The client starts a fresh continuation run that carries your answers and continues the agent. +```mermaid +sequenceDiagram + participant User + participant Client + participant Server + + Client->>Server: send message — run starts + Server-->>Client: interrupt outcome — run ends without a final answer + Client->>User: pending decisions surface as `interrupts` + User->>Client: approve / reject / submit a value + Client->>Server: continuation request with the answers — a fresh run + Server-->>Client: the agent picks up where it paused, final answer +``` + +Note that the pause spans **two runs**: the interrupted one ends, and the +continuation is a new run. One user-visible turn, two run lifecycles — see +[Threads and runs](../chat/streaming#threads-and-runs). + No database is required. The browser sends the full message history back on the continuation request, so a stateless server can rebuild the paused step and keep going. diff --git a/docs/media/audio-generation.md b/docs/media/audio-generation.md index 2fbb97651..4b323d1a4 100644 --- a/docs/media/audio-generation.md +++ b/docs/media/audio-generation.md @@ -161,6 +161,10 @@ flow. It mirrors the API of `useGenerateSpeech`, `useGenerateImage`, and other media hooks — see [Generation Hooks](./generation-hooks) for the full shape. +> **Note:** For long tracks, keep the run's status and result across a reload or +> a dropped connection — and the audio after the provider's URL expires — with +> [Generation Persistence](../persistence/generation-persistence). + ### Server (streaming SSE route) ```typescript diff --git a/docs/media/generation-hooks.md b/docs/media/generation-hooks.md index 69cb7b7c9..5ad594231 100644 --- a/docs/media/generation-hooks.md +++ b/docs/media/generation-hooks.md @@ -19,6 +19,14 @@ keywords: TanStack AI provides framework hooks for every generation type: image, audio, speech, transcription, summarization, and video. Each hook connects to a server endpoint and manages loading, error, and result state for you. +> **Surviving reloads and dropped connections:** every generation hook takes the +> same `persistence` option `useChat` does, so a long run's status and result +> come back after a page reload or a dropped connection. It restores by +> `threadId`, which for a generation names the slot successive runs fill +> (`product-7-hero`) rather than a conversation. See +> [Id map](../persistence/id-map). Setup is in +> [Generation Persistence](../persistence/generation-persistence). + ## Overview Generation hooks share a consistent API across all media types: @@ -33,7 +41,7 @@ Generation hooks share a consistent API across all media types: | `useGenerateVideo` | `VideoGenerateInput` | `VideoGenerateResult` | | `useGeneration` | Generic `TInput` | Generic `TResult` | -Every hook returns the same core shape: `generate`, `result`, `isLoading`, `error`, `status`, `stop`, and `reset`. You provide either a `connection` (streaming transport) or a `fetcher` (direct async call). +Every hook returns the same core shape: `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, and `runId` (the id of the job in flight, or `null`). You provide either a `connection` (streaming transport) or a `fetcher` (direct async call). ## Server Setup diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md index 9f20e59ab..fbe8f212e 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -230,20 +230,24 @@ HTTPS URLs, [Files API](https://ai.google.dev/gemini-api/docs/files) URIs, and `gs://` references all work without buffering the image in your runtime's memory. -Two paths have no URL passthrough and must upload real bytes — OpenAI's -`/images/edits` (and Sora `input_reference`), and Gemini **Veo** (its predict -API accepts only inline bytes or a `gs://` reference). For these, an HTTP(S) -URL input would have to be downloaded and buffered in memory, which can OOM -memory-constrained runtimes (e.g. Cloudflare Workers). So by default they -**throw** on an HTTP(S) URL image input rather than fetch it. Pass a `data:` -URI (or a `gs://` reference for Veo), or opt into fetching with `allowUrlFetch`: +Two paths have no URL passthrough and must upload real bytes: + +- OpenAI's `/images/edits`, and Sora `input_reference`. +- Gemini **Veo**: its predict API accepts only inline bytes or a `gs://` + reference. + +For these, an HTTP(S) URL input would have to be downloaded and buffered in +memory, which can OOM memory-constrained runtimes (e.g. Cloudflare Workers). So +by default they **throw** on an HTTP(S) URL image input rather than fetch it. +Pass a `data:` URI (or a `gs://` reference for Veo), or opt into fetching with +`allowUrlFetch`: ```typescript ignore import { createOpenaiImage } from '@tanstack/ai-openai/adapters' // Opt into downloading + buffering HTTP(S) URL image inputs (server runtimes // with headroom). data: URIs always work without this flag. -const adapter = createOpenaiImage('gpt-image-1', apiKey, { allowUrlFetch: true }) +const adapter = createOpenaiImage('gpt-image-2', apiKey, { allowUrlFetch: true }) ``` The same `allowUrlFetch` option exists on `createOpenaiVideo` and @@ -508,6 +512,9 @@ try { TanStack AI provides React hooks and server-side streaming helpers to build full-stack image generation with minimal boilerplate. +> **Note:** To keep a batch across reloads, or to keep the images after the +> provider's URLs expire, add [Generation Persistence](../persistence/generation-persistence). + ### Streaming Mode (Server Route + Client Hook) **Server** — Create an API route that wraps `generateImage` as a streaming response: diff --git a/docs/media/transcription.md b/docs/media/transcription.md index bd92c633a..adcc2eb32 100644 --- a/docs/media/transcription.md +++ b/docs/media/transcription.md @@ -232,7 +232,10 @@ for (const segment of result.segments ?? []) { } ``` -OpenAI accepts up to four known speaker references; `known_speaker_names` and `known_speaker_references` must be provided together with matching lengths. The diarization model does not support `prompt`, `include`, or `timestamp_granularities`; the adapter rejects those combinations before making the API request. +Two constraints the adapter enforces before it calls the API: + +- Up to four known speaker references. `known_speaker_names` and `known_speaker_references` must be provided together, with matching lengths. +- The diarization model does not support `prompt`, `include`, or `timestamp_granularities`. Those combinations are rejected. ## Response Format @@ -397,6 +400,10 @@ export async function POST(request: Request) { TanStack AI provides React hooks and server-side streaming helpers to build full-stack audio transcription with minimal boilerplate. +> **Note:** Transcribing a big file can run long — keep its status and result +> across a reload or a dropped connection with +> [Generation Persistence](../persistence/generation-persistence). + ### Streaming Mode (Server Route + Client Hook) **Server** — Create an API route that wraps `generateTranscription` as a streaming response: diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md index 2386de6fa..750cbdd9b 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -46,6 +46,15 @@ Currently supported: - **Grok (xAI)**: grok-imagine-video (text-to-video + image-to-video) and grok-imagine-video-1.5 (image-to-video only) models - **fal.ai**: MiniMax, Luma, Kling, Hunyuan, and other hosted video models +> **Video runs take minutes — don't lose them to a reload.** This is the +> strongest case for [Generation Persistence](../persistence/generation-persistence): +> it keeps a record of each run, so after a reload the hook shows that run's last +> known status and result instead of an empty form. A run that is still streaming +> against a durable server-side stream is re-attached and finished in place; +> otherwise the record is restored, not the provider work. And because provider video URLs expire, +> [keep the finished clip](../persistence/keep-generated-files) by saving its +> bytes to your own storage. + ## Basic Usage ### Creating a Video Job @@ -575,21 +584,25 @@ Adapters that haven't declared a per-model duration map keep the plain Gemini Omni Flash (`gemini-omni-flash-preview`) is Google's multimodal video-generation model with conversational editing. It only serves the -[Interactions API](https://ai.google.dev/gemini-api/docs/omni) — the same -`geminiVideo()` adapter routes it automatically: `generateVideo` creates a -background interaction, `getVideoJobStatus` polls it by id, and the -finished clip comes back **inline as a `data:video/mp4;base64,…` URL** -(when Google delivers by reference instead, the Files API URI passes -through and needs your API key to download, like Veo). - -Clips are 720p at 24 FPS, and `duration` accepts any value in the **3–10 -second** range (fractional seconds included), defaulting to 10 seconds when -omitted. `availableDurations()` reports -`{ kind: 'range', min: 3, max: 10, unit: 'seconds' }`; out-of-range -`duration` values are rejected at job creation, and `snapDuration(n)` snaps -raw seconds into the range (clamping to its bounds and rounding to whole -seconds). The `size` option maps onto the interaction's output aspect -ratio: +[Interactions API](https://ai.google.dev/gemini-api/docs/omni), and the same +`geminiVideo()` adapter routes it automatically: + +- `generateVideo` creates a background interaction. +- `getVideoJobStatus` polls it by id. +- The finished clip comes back **inline as a `data:video/mp4;base64,…` URL**. + When Google delivers by reference instead, the Files API URI passes through + and needs your API key to download, like Veo. + +Clips are 720p at 24 FPS. `duration` accepts any value in the **3 to 10 second** +range (fractional seconds included), defaulting to 10 seconds when omitted: + +- `availableDurations()` reports + `{ kind: 'range', min: 3, max: 10, unit: 'seconds' }`. +- Out-of-range `duration` values are rejected at job creation. +- `snapDuration(n)` snaps raw seconds into the range, clamping to its bounds and + rounding to whole seconds. + +The `size` option maps onto the interaction's output aspect ratio: ```typescript ignore import { generateVideo, getVideoJobStatus } from '@tanstack/ai' @@ -608,13 +621,14 @@ const status = await getVideoJobStatus({ adapter, jobId }) // status.url → 'data:video/mp4;base64,…' once completed ``` -Image and video prompt parts are sent to the interaction as content blocks -— grouped as images, then videos, then the text prompt (Omni doesn't use -Veo's `metadata.role` routing) — so you can condition the generation on -stills or short reference clips. `data` sources -are sent inline as base64; `url` sources pass through as-is — the adapter -never downloads them, so use Gemini Files API URIs (upload large media via -the Files API first). +Image and video prompt parts are sent to the interaction as content blocks, +grouped as images, then videos, then the text prompt (Omni doesn't use Veo's +`metadata.role` routing), so you can condition the generation on stills or short +reference clips. How each source is sent: + +- `data` sources are sent inline as base64. +- `url` sources pass through as-is. The adapter never downloads them, so use + Gemini Files API URIs (upload large media via the Files API first). #### Conversational video editing diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 7469992ef..49ac8512a 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -20,6 +20,42 @@ to end, then shows how to map the same contracts onto a database schema you already have. The runnable version of everything here lives in the `examples/ts-react-chat` app (`src/lib/sqlite-persistence.ts`). +## Which stores do you need? + +There are seven stores and you almost certainly do not want all of them. Each one +switches on a single capability. Find the column for what you are building and +implement the rows marked ✅: + +| Store | Save the transcript | Rejoin a run after reload | Durable approvals | App key/value | Persist generation runs | Keep generated files | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | +| `messages` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| `runs` | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| `interrupts` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| `metadata` | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| `generationRuns` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | +| `artifacts` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| `blobs` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | + +How to read it: + +- **Columns stack.** Want durable approvals *and* generated files? Implement the + union of those two columns. +- **The four chat columns all need `messages`.** `withPersistence` refuses to run + without it, so it is the floor for anything chat-related. +- **The two generation columns feed `withGenerationPersistence`** instead, and + need none of the chat stores. Chat and generation persistence are independent; + see [Generation persistence](./generation-persistence). + +Two pairs cannot be split: + +- `interrupts` needs `runs`. An interrupt record is scoped to a run. +- `artifacts` and `blobs` go together. Metadata with no bytes, or bytes with + nothing describing them, is not a usable combination. + +So the smallest adapter worth shipping is a single `messages` store, and the +common production shape is `messages` + `runs` + `interrupts`. The guide below +builds them in that order. + ## What an adapter is An adapter is an object with a `stores` map: @@ -36,27 +72,55 @@ const persistence: ChatTranscriptPersistence = { } ``` -Each store is independent. Provide only the ones you need: `messages` for the -transcript, `runs` for run lifecycle, `interrupts` for durable approvals (needs -`runs`), `metadata` for namespaced key/value state. The middleware turns on -behavior for whatever stores it finds, so a `messages`-only adapter is a valid -adapter. +Each store is independent, so provide only the ones you need. + +For chat: + +- `messages`: the transcript. +- `runs`: run lifecycle. +- `interrupts`: durable approvals. Needs `runs`. +- `metadata`: namespaced key/value state. + +For generation: + +- `generationRuns`: the generation run lifecycle. The counterpart to `runs`, + keyed by its own `runId`. +- `artifacts` + `blobs`: keep the generated media bytes. See + [Generation & media stores](#generation--media-stores). -Those four are the *only* keys `stores` accepts — anything else throws -`Unknown AIPersistence store key` at construction. Need a mutex across +The middleware turns on behavior for whatever stores it finds, so a +`messages`-only adapter is a valid adapter. + +Those seven — `messages`, `runs`, `interrupts`, `metadata`, `generationRuns`, +`artifacts`, `blobs` — are the *only* keys `stores` accepts; anything else +throws `Unknown AIPersistence store key` at construction. Need a mutex across instances? That is `withLocks`; see [Locks](../advanced/locks). -Type each store with its `define*Store` helper — `defineMessageStore`, -`defineRunStore`, `defineInterruptStore`, `defineMetadataStore` — as the sections -below do. Each checks the object against the contract inline (autocomplete, no +Type each store with its `define*Store` helper, as the sections below do. There +is one per store: + +- `defineMessageStore` +- `defineRunStore` +- `defineInterruptStore` +- `defineMetadataStore` +- `defineGenerationRunStore` +- `defineArtifactStore` +- `defineBlobStore` + +Each checks the object against the contract inline (autocomplete, no `: MessageStore` annotation) and composes into `defineAIPersistence`, which -tracks **exact presence**: the stores you pass are defined, autocompleted keys on -`persistence.stores`, and accessing one you did not pass is a compile error. +tracks **exact presence**: + +- The stores you pass are defined, and their keys autocomplete on + `persistence.stores`. +- Accessing one you did not pass is a compile error. -Annotate the value with a named shape — `ChatPersistence` for all four, -`ChatTranscriptPersistence` for the floor. Bare `AIPersistence` is the -all-optional bag, and `withPersistence` rejects it because `stores.messages` is -possibly `undefined`. +Annotate the value with a named shape: + +- `ChatPersistence`: all four chat stores. +- `ChatTranscriptPersistence`: the `messages` floor. +- `AIPersistence`: the all-optional bag. `withPersistence` rejects it, because + `stores.messages` is possibly `undefined`. Every method signature and invariant is in the [store interface reference](#store-interface-reference) at the end of this page. @@ -64,6 +128,59 @@ The invariants (idempotent creates, insert-if-absent, ordered listings) are what the shared conformance suite checks, and getting one wrong is the usual source of subtle bugs. +The records the stores hold form a small schema. The thread is not a table of +its own — it exists as the `thread_id` key the other records hang off — and +`metadata` is independent of all of it (its identity is `(namespace, key)`). +Note the asymmetry on the generation side. A chat run belongs to a thread, and +its own `run_id` is secondary. A generation run is keyed by its own `run_id` +first, and its `thread_id` names the slot the run fills, which is what +`findLatestForThread` hydrates by: + +```mermaid +erDiagram + MESSAGES ||--o{ RUN : "thread_id — a thread has many runs" + RUN ||--o{ INTERRUPT : "run_id — a run may pause on interrupts" + MESSAGES ||..o{ GENERATION_RUN : "thread_id, the slot a run fills" + GENERATION_RUN ||--o{ ARTIFACT : "run_id — a run produces artifacts" + ARTIFACT ||--|| BLOB : "blob_key — the bytes" + + MESSAGES { + string thread_id PK + json messages_json "full transcript, overwritten on save" + } + RUN { + string run_id PK + string thread_id + string status "running | completed | failed | interrupted" + int started_at + int finished_at + } + INTERRUPT { + string interrupt_id PK + string run_id + string thread_id + string status "pending | resolved | cancelled" + int requested_at + } + GENERATION_RUN { + string run_id PK + string thread_id "the slot this run fills" + string activity "image | audio | tts | video | transcription" + string status "running | completed | failed | interrupted" + } + ARTIFACT { + string artifact_id PK + string run_id + string blob_key "where the bytes live" + string mime_type + int size + } + BLOB { + string key PK + blob bytes + } +``` + ## New database: a SQLite adapter start to finish ### 1. The schema @@ -106,9 +223,11 @@ CREATE TABLE IF NOT EXISTS metadata ( ### 2. Messages: full-transcript overwrite -`saveThread` always receives the complete, authoritative history. It is a -replace, not an append. `loadThread` returns `[]` for a thread that was never -saved, never `null`. +Two contracts to hold: + +- `saveThread` always receives the complete, authoritative history. It is a + replace, not an append. +- `loadThread` returns `[]` for a thread that was never saved, never `null`. ```ts import { DatabaseSync } from 'node:sqlite' @@ -148,10 +267,12 @@ query instead. ### 3. Runs: idempotent create, patch, get -`createOrResume` must be idempotent. If the run id already exists, return the -stored record unchanged, so resuming a run never resets its `startedAt` or -status. `INSERT ... ON CONFLICT DO NOTHING` gives you that in one statement. -`update` on an unknown run id is a no-op. +Two contracts to hold: + +- `createOrResume` must be idempotent. If the run id already exists, return the + stored record unchanged, so resuming a run never resets its `startedAt` or + status. `INSERT ... ON CONFLICT DO NOTHING` gives you that in one statement. +- `update` on an unknown run id is a no-op. ```ts import { DatabaseSync } from 'node:sqlite' @@ -475,6 +596,504 @@ export async function POST(request: Request) { } ``` +## Generation & media stores + +Everything above builds a **chat** adapter. +[Media generation](./generation-persistence) persists differently: it does not +use the chat `runs` store at all. + +- **Required:** a `generationRuns` store, a `GenerationRunStore` keyed by + `runId` (the run/request id a generation mints). It is the counterpart to + `runs`. +- **Optional, to keep the generated bytes:** an `artifacts` store (metadata) and + a `blobs` store (the bytes). These two must be provided **together**. + +`threadId` is the slot the run belongs to, recorded on each run record. + +These are three more tables alongside the four from the schema in step 1: + +```sql +CREATE TABLE IF NOT EXISTS generation_runs ( + run_id text PRIMARY KEY NOT NULL, + thread_id text NOT NULL, + activity text NOT NULL, + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error_json text, + result_json text, + artifacts_json text, + usage_json text +); +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + blob_key text, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + source_url text, + created_at integer NOT NULL +); +CREATE TABLE IF NOT EXISTS blobs ( + key text PRIMARY KEY NOT NULL, + bytes blob NOT NULL, + size integer NOT NULL, + etag text NOT NULL, + content_type text, + custom_metadata_json text, + created_at integer NOT NULL, + updated_at integer NOT NULL +); +``` + +### Generation runs: idempotent create, patch, latest-for-thread + +`GenerationRunStore` is the generation analogue of `RunStore`. Three contracts +to hold: + +- `createOrResume` is idempotent. A second call for a `runId` returns the stored + record unchanged, so resuming a run never resets its `startedAt`, `activity`, + or status. `INSERT ... ON CONFLICT DO NOTHING` gives you that. +- `update` on an unknown `runId` is a no-op. +- `findLatestForThread` returns the run with the greatest `startedAt` linked to a + thread. `reconstructGeneration` calls it to hydrate the last generation for a + thread on a server-driven client's mount. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineGenerationRunStore } from '@tanstack/ai-persistence' +import type { + GenerationRunRecord, + GenerationRunStatus, +} from '@tanstack/ai-persistence' + +function toGenerationRunStatus(value: unknown): GenerationRunStatus { + switch (value) { + case 'running': + case 'completed': + case 'failed': + case 'interrupted': + return value + default: + throw new TypeError(`Unexpected generation run status: ${String(value)}`) + } +} + +// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field +// (String / Number / typeof) and JSON-parse the text columns — no cast. +function mapGenerationRun(row: Record): GenerationRunRecord { + return { + runId: String(row.run_id), + threadId: String(row.thread_id), + activity: String(row.activity), + provider: String(row.provider), + model: String(row.model), + status: toGenerationRunStatus(row.status), + startedAt: Number(row.started_at), + ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), + ...(typeof row.error_json === 'string' + ? { error: JSON.parse(row.error_json) } + : {}), + ...(typeof row.result_json === 'string' + ? { result: JSON.parse(row.result_json) } + : {}), + ...(typeof row.artifacts_json === 'string' + ? { artifacts: JSON.parse(row.artifacts_json) } + : {}), + ...(typeof row.usage_json === 'string' + ? { usage: JSON.parse(row.usage_json) } + : {}), + } +} + +function createGenerationRunStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM generation_runs WHERE run_id = ?') + const insert = db.prepare( + `INSERT INTO generation_runs + (run_id, thread_id, activity, provider, model, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const latest = db.prepare( + `SELECT * FROM generation_runs WHERE thread_id = ? + ORDER BY started_at DESC LIMIT 1`, + ) + return defineGenerationRunStore({ + async createOrResume(input) { + const existing = select.get(input.runId) + if (existing) return mapGenerationRun(existing) + const status: GenerationRunStatus = input.status ?? 'running' + insert.run( + input.runId, + input.threadId, + input.activity, + input.provider, + input.model, + status, + input.startedAt, + ) + return { + runId: input.runId, + threadId: input.threadId, + activity: input.activity, + provider: input.provider, + model: input.model, + status, + startedAt: input.startedAt, + } + }, + async update(runId, patch) { + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error_json = ?') + params.push(JSON.stringify(patch.error)) + } + if (patch.result !== undefined) { + sets.push('result_json = ?') + params.push(JSON.stringify(patch.result)) + } + if (patch.artifacts !== undefined) { + sets.push('artifacts_json = ?') + params.push(JSON.stringify(patch.artifacts)) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + // Empty patch, or an unknown run id, touches nothing (UPDATE no-ops). + if (sets.length === 0) return + params.push(runId) + db.prepare( + `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, + ).run(...params) + }, + async get(runId) { + const row = select.get(runId) + return row ? mapGenerationRun(row) : null + }, + // The most recent run linked to a thread. `reconstructGeneration` calls this + // so a server-driven client (`persistence: true`) hydrates the last + // generation for its thread by the stable thread id, without a run id. + async findLatestForThread(threadId) { + const row = latest.get(threadId) + return row ? mapGenerationRun(row) : null + }, + }) +} +``` + +### Artifacts: media metadata + +`ArtifactStore` holds one metadata row per generated file: its `runId`, +`mimeType`, `size`, and a `createdAt`. The bytes live in the blob store below. + +- `save` is an upsert. +- `list(runId)` returns every artifact for a run, `[]` when there are none. +- `delete` / `deleteForRun` are required. Retention and erasure are the point of + storing media durably, and they mirror `BlobStore.delete`. + +Persist `blobKey` verbatim. It records where these bytes actually went, and a +`storageKey` mapper can put them anywhere, so a reader cannot recompute the +path — `resolveArtifactBlobKey(record)` falls back to the default convention +only for rows written before the column existed. Drop it and every artifact +stored under a custom key becomes unreadable. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineArtifactStore } from '@tanstack/ai-persistence' +import type { ArtifactRecord } from '@tanstack/ai-persistence' + +function mapArtifact(row: Record): ArtifactRecord { + return { + artifactId: String(row.artifact_id), + runId: String(row.run_id), + threadId: String(row.thread_id), + ...(typeof row.blob_key === 'string' ? { blobKey: row.blob_key } : {}), + name: String(row.name), + mimeType: String(row.mime_type), + size: Number(row.size), + ...(typeof row.source_url === 'string' + ? { sourceUrl: row.source_url } + : {}), + createdAt: Number(row.created_at), + } +} + +function createArtifactStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO artifacts + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, + ) + const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') + const byRun = db.prepare( + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + ) + return defineArtifactStore({ + async save(record) { + upsert.run( + record.artifactId, + record.runId, + record.threadId, + record.blobKey ?? null, + record.name, + record.mimeType, + record.size, + record.sourceUrl ?? null, + record.createdAt, + ) + }, + async get(artifactId) { + const row = selectOne.get(artifactId) + return row ? mapArtifact(row) : null + }, + async list(runId) { + return byRun.all(runId).map(mapArtifact) + }, + async delete(artifactId) { + db.prepare('DELETE FROM artifacts WHERE artifact_id = ?').run(artifactId) + }, + async deleteForRun(runId) { + db.prepare('DELETE FROM artifacts WHERE run_id = ?').run(runId) + }, + }) +} +``` + +### Blobs: the bytes + +`BlobStore` is a small object store. `withGenerationPersistence` writes each +generated file under the key `artifacts//`, so a +prefix-filtered `list({ prefix: 'artifacts//' })` enumerates a run's +media. + +- `put` accepts any `BlobBody`: a stream, buffer, string, or `Blob`. The helper + below normalizes it to bytes. +- `list` matches `prefix` literally and pages with a keyset cursor. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineBlobStore } from '@tanstack/ai-persistence' +import type { + BlobBody, + BlobObject, + BlobRecord, +} from '@tanstack/ai-persistence' + +async function toBytes(body: BlobBody): Promise { + if (typeof body === 'string') return new TextEncoder().encode(body) + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice() + } + if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()) + } + // ReadableStream: drain it into one buffer. + const reader = body.getReader() + const chunks: Array = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function mapBlobRecord(row: Record): BlobRecord { + return { + key: String(row.key), + ...(row.size != null ? { size: Number(row.size) } : {}), + ...(typeof row.etag === 'string' ? { etag: row.etag } : {}), + ...(typeof row.content_type === 'string' + ? { contentType: row.content_type } + : {}), + ...(typeof row.custom_metadata_json === 'string' + ? { customMetadata: JSON.parse(row.custom_metadata_json) } + : {}), + ...(row.created_at != null ? { createdAt: Number(row.created_at) } : {}), + ...(row.updated_at != null ? { updatedAt: Number(row.updated_at) } : {}), + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + return { + ...record, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice()) + controller.close() + }, + }), + arrayBuffer() { + const copy = new ArrayBuffer(bytes.byteLength) + new Uint8Array(copy).set(bytes) + return Promise.resolve(copy) + }, + text: () => Promise.resolve(new TextDecoder().decode(bytes)), + } +} + +function createBlobStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO blobs + (key, bytes, size, etag, content_type, custom_metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + bytes = excluded.bytes, size = excluded.size, etag = excluded.etag, + content_type = excluded.content_type, + custom_metadata_json = excluded.custom_metadata_json, + updated_at = excluded.updated_at`, + ) + const selectCreated = db.prepare('SELECT created_at FROM blobs WHERE key = ?') + const selectOne = db.prepare('SELECT * FROM blobs WHERE key = ?') + return defineBlobStore({ + async put(key, body, options) { + const bytes = await toBytes(body) + const now = Date.now() + const prior = selectCreated.get(key) + const createdAt = + prior && prior.created_at != null ? Number(prior.created_at) : now + const etag = String(now) + upsert.run( + key, + bytes, + bytes.byteLength, + etag, + options?.contentType ?? null, + options?.customMetadata ? JSON.stringify(options.customMetadata) : null, + createdAt, + now, + ) + return { + key, + size: bytes.byteLength, + etag, + createdAt, + updatedAt: now, + ...(options?.contentType !== undefined + ? { contentType: options.contentType } + : {}), + ...(options?.customMetadata !== undefined + ? { customMetadata: options.customMetadata } + : {}), + } + }, + async get(key) { + const row = selectOne.get(key) + if (!row) return null + const bytes = + row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array() + return blobObject(mapBlobRecord(row), bytes) + }, + async head(key) { + const row = selectOne.get(key) + return row ? mapBlobRecord(row) : null + }, + async delete(key) { + db.prepare('DELETE FROM blobs WHERE key = ?').run(key) + }, + async list(options) { + if (options?.limit === 0) return { objects: [], truncated: false } + // Match the prefix with `substr(...) = ?` rather than LIKE: SQLite's LIKE + // is case-INsensitive for ASCII and treats `%`/`_` as wildcards, while the + // contract says a prefix matches literally and case-sensitively. Then page + // with a keyset cursor (keys strictly greater than the last one returned). + const prefix = options?.prefix ?? '' + const params: Array = [prefix, prefix] + let where = 'substr(key, 1, length(?)) = ?' + if (options?.cursor !== undefined) { + where += ' AND key > ?' + params.push(options.cursor) + } + let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` + const limit = options?.limit + if (limit !== undefined) { + sql += ' LIMIT ?' // fetch one extra row to detect truncation + params.push(limit + 1) + } + const rows = db + .prepare(sql) + .all(...params) + .map(mapBlobRecord) + if (limit !== undefined && rows.length > limit) { + const page = rows.slice(0, limit) + const cursor = page.at(-1)?.key + return { + objects: page, + truncated: true, + ...(cursor !== undefined ? { cursor } : {}), + } + } + return { objects: rows, truncated: false } + }, + }) +} +``` + +### Assemble a generation adapter + +Hand the three stores to `defineAIPersistence` the same way. `generationRuns` alone is a +valid generation adapter (run records, no byte storage); add `artifacts` + +`blobs` — together — to keep the media: + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineAIPersistence } from '@tanstack/ai-persistence' +// The three generation store factories and the schema string, from your modules. +import { createArtifactStore } from './artifact-store' +import { createBlobStore } from './blob-store' +import { createGenerationRunStore } from './generation-run-store' +import { GENERATION_SCHEMA_SQL } from './generation-schema' + +export function generationPersistence(options: { + url: string + migrate?: boolean +}) { + const db = new DatabaseSync(options.url) + if (options.migrate) db.exec(GENERATION_SCHEMA_SQL) + return defineAIPersistence({ + stores: { + generationRuns: createGenerationRunStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), + }, + }) +} +``` + +Pass the result to `withGenerationPersistence` on a `generateImage` / +`generateVideo` / … call; see [Generation persistence](./generation-persistence). +You can also fold these stores into an existing chat adapter with +`composePersistence`, so one backend serves both `withPersistence` and +`withGenerationPersistence`. + ## Existing database: map the contracts onto your schema You do not have to create the four tables above. If you already have a database, @@ -529,8 +1148,20 @@ runPersistenceConformance('my sqlite adapter', () => ) ``` -The adapter above provides all four stores, so there is nothing to declare. A -partial adapter lists what it deliberately omits: +The suite covers all seven stores — the four chat state stores and the three +generation stores from the section above — so an adapter lists whatever it +deliberately omits. A chat-only adapter skips the generation half: + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { chatOnlyPersistence } from './chat-only' + +runPersistenceConformance('chat-only adapter', () => chatOnlyPersistence(), { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) +``` + +and a transcript-only one skips more: ```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' @@ -539,15 +1170,24 @@ import { transcriptOnlyPersistence } from './transcript-only' runPersistenceConformance( 'transcript-only adapter', () => transcriptOnlyPersistence(), - { skip: ['runs', 'interrupts', 'metadata'] }, + { + skip: [ + 'runs', + 'interrupts', + 'metadata', + 'generationRuns', + 'artifacts', + 'blobs', + ], + }, ) ``` -`skip` accepts only the four state store keys. A store that is absent and not -listed fails the suite loudly, so you cannot ship a half-wired adapter by -accident. When this is green, your adapter is a drop-in for `withPersistence`. -The `examples/ts-react-chat` app runs exactly this test against its SQLite -backend. +`skip` accepts only store keys. A store that is absent and not listed fails the +suite loudly, so you cannot ship a half-wired adapter by accident. When this is +green, your adapter is a drop-in for `withPersistence` (and, with the generation +stores, `withGenerationPersistence`). The `examples/ts-react-chat` app runs +exactly this test against its SQLite backend, which provides all seven. ## Let your coding agent write it @@ -646,14 +1286,17 @@ interface RunStore { } ``` -Implement `createOrResume` idempotently: a second call for an existing `runId` -returns the stored record unchanged, which is what makes resuming a run safe. -`update` against an unknown `runId` is a no-op. Retries may repeat the same run -id. `findActiveRun` must do real work: stub it to `null` and `reconstructChat` -always reports `activeRun: null`, so a client that reloads (or switches back to) -a still-generating thread restores the transcript but never resumes the live -reply — and nothing detects it, because `null` is also the right answer for an -idle thread. +Three contracts to hold: + +- `createOrResume` must be idempotent. A second call for an existing `runId` + returns the stored record unchanged, which is what makes resuming a run safe. + Retries may repeat the same run id. +- `update` against an unknown `runId` is a no-op. +- `findActiveRun` must do real work. Stub it to `null` and `reconstructChat` + always reports `activeRun: null`, so a client that reloads (or switches back + to) a still-generating thread restores the transcript but never resumes the + live reply. Nothing detects it either, because `null` is also the right answer + for an idle thread. Every method on a store you provide is required. A backend that genuinely has no run lifecycle should declare `ChatTranscriptStores` and omit `runs` entirely @@ -707,6 +1350,161 @@ composite identity. A stored `null` is indistinguishable from absence at the typ level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or reject nullish values outright the way the SQLite store above does. +### GenerationRunStore + +The generation counterpart to `RunStore`. Keyed by its own `runId`, with +`threadId` the slot `findLatestForThread` looks runs up by. +`withGenerationPersistence` requires this store, not `runs`. + +Its `status` uses the same vocabulary as a chat run's `RunStatus`, so one status +column and one set of checks cover both tables. + +```ts +import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai' + +// The same vocabulary as a chat run's `RunStatus`. +type GenerationRunStatus = 'running' | 'completed' | 'failed' | 'interrupted' + +interface GenerationRunRecord { + runId: string + threadId: string // the slot this run fills, hydrated by findLatestForThread + activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription' + provider: string + model: string + status: GenerationRunStatus + startedAt: number // epoch ms + finishedAt?: number // epoch ms, set once the run reaches a terminal status + error?: { message: string; code?: string } + result?: unknown // terminal result metadata (ids, urls) — never media bytes + artifacts?: Array // present with an artifacts + blobs backend + usage?: TokenUsage +} + +interface GenerationRunStore { + createOrResume(input: { + runId: string + activity: string + provider: string + model: string + startedAt: number + threadId: string + status?: GenerationRunStatus + }): Promise + update( + runId: string, + patch: Partial< + Pick< + GenerationRunRecord, + 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' + > + >, + ): Promise + get(runId: string): Promise + // The most recent run filed under a thread (greatest `startedAt`), or null. + // Required: it is the only query that hydrates a generation, so an adapter + // without it would be indistinguishable from one whose thread has no runs — + // `persistence: true` would silently restore nothing, forever. + findLatestForThread(threadId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `runId` +returns the stored record unchanged (`startedAt` / `activity` / `provider` / +`model` / `threadId` are not mutated), which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. + +### ArtifactStore + +Metadata rows for persisted media. The bytes live in a `BlobStore`; this record +holds the descriptive metadata and an optional `sourceUrl` for reference-only +backends. Provide it together with a `BlobStore` to keep generated bytes. + +```ts +interface ArtifactRecord { + artifactId: string + runId: string + threadId: string + blobKey?: string // where the bytes live; absent on pre-blobKey records + name: string + mimeType: string + size: number + sourceUrl?: string // where the bytes were fetched FROM (provenance) + createdAt: number // epoch ms +} + +interface ArtifactStore { + save(record: ArtifactRecord): Promise + get(artifactId: string): Promise + list(runId: string): Promise> // [] when the run has none + delete(artifactId: string): Promise + deleteForRun(runId: string): Promise +} +``` + +### BlobStore + +A durable object/blob store for the bytes. `withGenerationPersistence` writes +each generated file under the key `artifacts//`. + +```ts +type BlobBody = + | ReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | Blob + +interface BlobRecord { + key: string + size?: number + etag?: string + contentType?: string + customMetadata?: Record + createdAt?: number // epoch ms first written + updatedAt?: number // epoch ms last overwritten +} + +interface BlobObject extends BlobRecord { + arrayBuffer(): Promise + text(): Promise + body?: ReadableStream +} + +interface BlobListPage { + objects: Array + cursor?: string // present only when `truncated` + truncated?: boolean +} + +interface BlobPutOptions { + contentType?: string + customMetadata?: Record +} + +interface BlobListOptions { + prefix?: string + cursor?: string + limit?: number +} + +interface BlobStore { + put(key: string, body: BlobBody, options?: BlobPutOptions): Promise + get(key: string): Promise + head(key: string): Promise + delete(key: string): Promise + list(options?: BlobListOptions): Promise +} +``` + +Three contracts to hold for `list`: + +- `prefix` matches literally and case-sensitively. Escape SQL `LIKE` + metacharacters. +- When `limit` is given and more keys match, return `truncated: true` with a + `cursor`. Passing that cursor back returns the strictly-following keys, so + paging visits every key exactly once. +- `limit: 0` yields an empty, untruncated page. + ## Where to go next - [Controls](./controls): compose stores from different systems. diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index d0ae5c9d3..c8d6f3025 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -69,6 +69,28 @@ Creating tables on open is convenient for local development. In production, appl schema changes through your deployment workflow instead. See [Migrations](./migrations). +## Threads, runs, and turns + +Threads and runs are protocol concepts, not persistence ones — a **thread** +(`threadId`) is the stable conversation, a **run** (`runId`) one +`RUN_STARTED` → `RUN_FINISHED` execution, and one user-visible turn can span +several runs. [Threads and runs](../chat/streaming#threads-and-runs) +in the streaming guide covers the anatomy. What persistence adds is the durable +record of them, anchored on the thread: + +- The transcript is stored per `threadId` (the `messages` store). +- Each run gets a `runs` record with status, timings, and usage — the id is + ephemeral, the record is not. +- A reconnecting client (a reload, or the same thread on another device) never + has to present a run id it may no longer know: the store resolves the + thread's live run (`findActiveRun(threadId)`) and the client tails that. +- Interrupt records carry both ids — the `runId` of the execution they paused + and the `threadId` of the conversation they live in. + +[Id map](./id-map) is the practical companion to this: how to choose a thread +id, why both client and server must file under the same one, when to read +`useChat`'s `runId`, and what the same two ids mean on the generation hooks. + ## Send the full transcript, or none of it `withPersistence` follows one rule, the authoritative-history contract: @@ -101,10 +123,29 @@ Streaming snapshots default off (finish is the authoritative save); enable them to trade extra writes for partial-output durability. Tune the interval with `snapshotIntervalMs` (default `1000`). -On **error**, the run is marked `failed`. On **abort**, the run is marked -`interrupted`. Resumes accepted in `onConfig` are **not** consumed until a -success boundary (interrupt or finish), so a failed run leaves pending -interrupts retryable with the same resume batch. +How a run that does not finish cleanly is recorded: + +- On **error**, the run is marked `failed`. +- On **abort**, the run is marked `interrupted`. + +Resumes accepted in `onConfig` are **not** consumed until a success boundary (an +interrupt or a finish), so a failed run leaves pending interrupts retryable with +the same resume batch. + +Every run record moves through this lifecycle — all three end states are +terminal for that record, because a continuation after an interrupt is a new run +with a fresh `runId`: + +```mermaid +stateDiagram-v2 + [*] --> running : run starts (idempotent createOrResume) + running --> completed : finish — transcript saved first + running --> failed : error + running --> interrupted : interrupt boundary, or abort + completed --> [*] + failed --> [*] + interrupted --> [*] : continuation runs under a new runId +``` ## Interrupts survive a restart @@ -122,6 +163,18 @@ needs client message history the persistence flow deliberately omits). Resumes are committed (resolved/cancelled in the store) only once the run reaches a successful interrupt or finish boundary. +An interrupt record is born `pending` and only a commit moves it — which is why +a failed continuation leaves it answerable again: + +```mermaid +stateDiagram-v2 + [*] --> pending : run pauses — interrupt recorded + pending --> resolved : resume answers it, committed at a success boundary + pending --> cancelled : resume cancels it + resolved --> [*] + cancelled --> [*] +``` + ## Where to go next - Bring durability to the browser too, so a full page reload restores the diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md index 29b1103a9..5caae8a15 100644 --- a/docs/persistence/client-persistence.md +++ b/docs/persistence/client-persistence.md @@ -46,6 +46,11 @@ function Chat() { `localStoragePersistence()` needs no type argument and no codec: it defaults to the chat record shape and a JSON codec. That is the whole opt-in. +The `threadId` is doing the real work here: it is the key the record is written +under and looked up by, so a fresh id per mount restores nothing. Derive it from +your own domain (a conversation id, a route param). [Id map](./id-map) covers how +to pick one and how it differs from the `runId` the hook reports. + ## What a reload restores The client stores one record per `threadId`, the transcript plus a small resume @@ -70,6 +75,15 @@ pointer. On the next load `useChat` reads it and: - **`true`** is server-authoritative. - **`false`** (or omitted) is off: messages live in memory only and a reload starts empty. +The generation hooks (`useGenerateImage` / `useGenerateVideo` / …) take a +`persistence` option too, but theirs is **boolean only**: the record lives on the +server and the browser caches nothing. They key on `threadId` as well, where it +names a slot successive runs fill rather than a conversation +([Id map](./id-map)). A reload restores the last known `status` / `result` / +`error` for that slot's newest run. It does not restart provider work; only a run +still streaming against a server-side durable stream is re-attached and finished +in place. See [Generation persistence](./generation-persistence) for the setup. + ### An adapter: client-authoritative Pass the adapter directly, `persistence: localStoragePersistence()`. The diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index cbb49777b..5c70734f4 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -89,7 +89,7 @@ values arrive from untyped JavaScript. - `withPersistence` requires `messages`. - `interrupts` requires `runs`: an interrupt record is scoped to a run. -- `withGenerationPersistence` requires `runs`. +- `withGenerationPersistence` requires `generationRuns`. To define a partial backend directly rather than by composing, use `defineAIPersistence({ stores: { ... } })` and pass only the stores you have. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md new file mode 100644 index 000000000..fc441d19b --- /dev/null +++ b/docs/persistence/generation-persistence.md @@ -0,0 +1,453 @@ +--- +title: Generation Persistence +id: generation-persistence +--- + +# Generation Persistence + +Media generation takes time, and video can take minutes. If the user reloads the +page or their connection drops mid-run, that run is easy to lose. Generation +persistence keeps a small record of each run and restores it into the hook's +normal `status` / `result` / `error` fields, so a reload reads exactly like a +fresh run. + +Reach for it when a run is long enough that a reload matters: video, batch +images, long audio, a big transcription. For a quick one-shot image you show and +forget, skip it. + +## The id it all hangs on + +All of this keys on `threadId`, and for a generation that is a **slot**, not a +conversation: a stable, app-chosen name for the place successive runs fill +(`product-7-hero`, `video-9-start-frame`). Each run gets its own `runId` and its +own record, and restore hands back the newest run in the slot. + +Pass the same string to the hook and to the activity, and keep it stable across +reloads. The middleware reads it off the activity, so you never repeat it there. + +[Id map](./id-map) covers how to choose one and what goes wrong when it drifts. + +## Turn it on + +`persistence` is a boolean: + +- **`persistence: true`**: the server keeps the record. On mount the hook + hydrates the last run for its `threadId` and repaints it. +- **omitted / `false`**: off. The run lives in memory only, and a reload starts + empty. + +The record lives on the server, written by `withGenerationPersistence`, which +needs a `generationRuns` store (a `GenerationRunStore` keyed by the run's own +`runId`, with the `threadId` recorded as the slot the run belongs to). +`memoryPersistence()` ships one out of the box; see +[Build your own adapter](./build-your-own-adapter#generation--media-stores) for +your own backend. + +The browser caches nothing, so a generation's history is never duplicated into +local storage and a second device sees the same run. + +The record never holds the generated bytes, so on its own a reload restores +`status` and `error` while `result` stays `null`. To bring the media back too, +add server byte storage: see [Keep generated files](./keep-generated-files). + +The record's lifecycle is small — one status field the middleware advances: + +```mermaid +stateDiagram-v2 + [*] --> running : generation starts (idempotent createOrResume) + running --> completed : finish, result metadata saved + running --> failed : error + running --> interrupted : abort + completed --> [*] + failed --> [*] + interrupted --> [*] +``` + +A restored `interrupted` run surfaces to the hook as an error — an aborted +generation cannot be resumed, only re-run. + +## Wire the route + +One `POST` that runs the generation, and a `GET` on the same route that answers +the mount-time hydration with `reconstructGeneration`: + +```ts group=generation-server-driven +import { + generateImage, + generationParamsFromRequest, + memoryStream, + resumeServerSentEventsResponse, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const durability = memoryStream(request) + const { input, threadId } = await generationParamsFromRequest('image', request) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + // Persistence requires the scope, so a request without one cannot be served: + // the run would be filed nowhere the client could hydrate from. + if (threadId === undefined) { + return new Response('`threadId` is required', { status: 400 }) + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + // The same scope the GET below finds the last run for. + threadId, + stream: true, + // `artifactUrl` makes the restored media render from your own origin. It is + // optional — see Keep generated files for the serve route it points at. + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, + }), + ], + }) + + return toServerSentEventsResponse(stream, { + durability: { adapter: durability }, + }) +} + +export function GET(request: Request): Response | Promise { + const durability = memoryStream(request) + // A reconnecting client carries a resume cursor; replay the live stream. + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + // Otherwise this is the mount-time hydration (?threadId). In multi-user apps + // pass reconstructGeneration's `authorize` option so a guessed threadId can't + // read another user's generation. + return reconstructGeneration(persistence, request) +} +``` + +On the client, pass a connection, a stable `threadId`, and `persistence: true`. +The hook is transparent: a reload repaints the last run into the same fields a +fresh run uses, the way `useChat` restores into `messages`: + +```tsx +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' + +const connection = fetchServerSentEvents('/api/generate/image') + +export function HeroImageGenerator({ threadId }: { threadId: string }) { + const image = useGenerateImage({ + threadId, + connection, + persistence: true, + }) + + return ( +
+ + + {image.status === 'success' ? ( +

Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.

+ ) : null} + {image.error ?

Last run failed: {image.error.message}

: null} + {image.result?.images.map((img, index) => + img.url ? : null, + )} +
+ ) +} +``` + +Because the server stamps a durable `artifactUrl`, the restored +`image.result.images[i].url` serves from your own origin, so the image renders +after a reload just as it did live. You never fetch or seed anything: the thread +id is the stable key, and a reload or the same thread on another device follow +the identical path. + +If a run is **still generating** when the connection drops or the page reloads, +the client re-attaches to it and finishes it in place, exactly like `useChat`. +The `durability` adapter on `toServerSentEventsResponse` plus the `GET` resume +branch above are all it needs: on mount `reconstructGeneration` reports the live +run and the client tails it through the durability log. In production, swap +`memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where +requests span processes. See [Resumable Streams](../resumable-streams/overview). + +```mermaid +sequenceDiagram + participant Hook as useGenerateImage (persistence: true) + participant Route as GET /api/generate/image + participant Runs as generationRuns store + participant Log as Delivery log + + Note over Hook: mount (or reload) with a threadId + Hook->>Route: ?threadId=… + Route->>Runs: reconstructGeneration — latest run for the thread + Runs-->>Route: run record (status, result metadata, artifact refs) + Route-->>Hook: status / error / result repainted + alt run still generating + Hook->>Route: ?runId=…&offset=-1 + Route->>Log: resumeServerSentEventsResponse + Log-->>Hook: replay + live tail — run finishes in place + end +``` + +## Server functions / direct + +The HTTP adapters above implement hydration and rejoin for you. With +[TanStack Start](https://tanstack.com/start) server functions (or any direct, +in-process call) there is no `GET` route to hang them on, so you supply the +two handlers yourself — one for mount-time hydration, one for replaying an +in-flight run — and pass them as options alongside the `fetcher` (or to +`stream()` / `rpcStream()`). + +Three server functions cover it: one runs the generation, one answers +hydration with `getGenerationHydration`, and one replays the run's durability +log with `replayRunStream`. Both streaming functions return the same thing — +an SSE `Response` from `toServerSentEventsResponse` — so the client decodes +them the same way: + +```ts group=generation-server-functions +// server/image.ts +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' +import { + generateImage, + memoryStream, + replayRunStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + getGenerationHydration, + memoryPersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import type { ImageGenerateInput } from '@tanstack/ai-client' + +const persistence = memoryPersistence() + +export const generateImageFn = createServerFn({ method: 'POST' }) + .inputValidator((data: ImageGenerateInput & { threadId: string }) => data) + .handler(({ data: { threadId, ...input } }) => { + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + // One run id for both the durability log and the run record, so the + // rejoin below replays exactly the run hydration reports. + const runId = crypto.randomUUID() + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + threadId, + runId, + stream: true, + // `artifactUrl` is optional — see Keep generated files. + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, + }), + ], + }) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream({ runId }) }, + }) + }) + +/** + * The wire shape for mount hydration, parsed with Zod so that: + * - TanStack Start gets a JSON-serializable return type (the run record's + * `result` is loosely typed, and Start's serializer rejects `unknown`) + * - the stored record is validated before it crosses the wire + */ +const hydrationSchema = z.object({ + resumeSnapshot: z + .object({ + schemaVersion: z.literal(1), + resumeState: z + .object({ threadId: z.string(), runId: z.string() }) + .nullable(), + status: z.enum(['idle', 'running', 'complete', 'error']), + // `z.any()` (not `z.unknown()`) so Start's serializer accepts the values. + result: z.record(z.string(), z.any()).optional(), + error: z + .object({ message: z.string(), code: z.string().optional() }) + .optional(), + activity: z.string().optional(), + }) + .nullable(), + activeRun: z.object({ runId: z.string() }).nullable(), +}) + +export const getImageHydrationFn = createServerFn({ method: 'GET' }) + .inputValidator(z.string().min(1)) + .handler(async ({ data: threadId }) => { + // `getGenerationHydration` does no auth — gate on your session here, the + // way you would pass `authorize` to `reconstructGeneration`. + return hydrationSchema.parse( + await getGenerationHydration(persistence, threadId), + ) + }) + +export const joinImageRunFn = createServerFn({ method: 'GET' }) + .inputValidator(z.string().min(1)) + .handler(({ data: runId }) => + // Same SSE envelope `generateImageFn` returns, so one client-side decoder + // covers both. + toServerSentEventsResponse(replayRunStream(memoryStream({ runId }))), + ) +``` + +On the client, pass the two handlers next to the `fetcher`. A reload now +hydrates the last run through `getImageHydrationFn`, and a run still +generating is tailed to completion through `joinImageRunFn`. `joinRun` yields +`StreamChunk`s rather than a `Response`, so decode the SSE body yourself and +apply the `signal` there — the server function itself takes only its `data`: + +```tsx +import { useGenerateImage } from '@tanstack/ai-react' +import type { StreamChunk } from '@tanstack/ai' +import { + generateImageFn, + getImageHydrationFn, + joinImageRunFn, +} from './server/image' + +/** Decode an SSE `Response` from a server function into `StreamChunk`s. */ +async function* chunksFromSseResponse( + response: Response, + signal?: AbortSignal, +): AsyncGenerator { + if (!response.ok) throw new Error(`Join failed: ${response.status}`) + const reader = response.body?.getReader() + if (!reader) return + const decoder = new TextDecoder() + let buffer = '' + try { + while (!signal?.aborted) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data:')) continue + const data = line.slice(5).trimStart() + // `JSON.parse` returns the chunk the server encoded. + if (data) yield JSON.parse(data) + } + } + } finally { + reader.releaseLock() + } +} + +export function HeroImageGenerator({ threadId }: { threadId: string }) { + const image = useGenerateImage({ + threadId, + fetcher: (input) => generateImageFn({ data: { ...input, threadId } }), + hydrateGeneration: (id) => getImageHydrationFn({ data: id }), + joinRun: async function* (runId, signal) { + const response = await joinImageRunFn({ data: runId }) + if (!(response instanceof Response)) { + throw new Error('joinImageRunFn should return an SSE Response') + } + yield* chunksFromSseResponse(response, signal) + }, + persistence: true, + }) + // The render is identical to the HTTP example above. + return

{image.status}

+} +``` + +A non-streaming `fetcher` (a plain `Promise` rather than +an SSE `Response`) has no in-flight stream to rejoin, so it needs only +`hydrateGeneration` — drop `joinRun` and the decoder with it. + +A restored run that was still generating but has **no** `joinRun` handler to +tail it surfaces as an interrupted error — it cannot be resumed, only re-run — +instead of hanging on `generating` forever. + +The same handlers fit the lightweight connection adapters directly — +`stream(factory, { hydrateGeneration, joinRun })` and +`rpcStream(call, { hydrateGeneration, joinRun })` — for in-process or RPC +transports; they also accept a chat `hydrate` handler for `useChat`'s +server-driven persistence. + +## What a reload restores + +Two things to keep in mind, whichever wiring you used: + +- **The hook is transparent.** After a reload it repaints `status` + (`'idle'` / `'generating'` / `'success'` / `'error'`), `error`, and `result` + just like a live run. There is no snapshot field to render yourself. +- **`result` needs byte storage to come back.** The run record holds result + metadata, never the media bytes, so on its own a reload restores `status` and + `error` while `result` stays `null`. Add byte storage and `artifactUrl` + ([Keep generated files](./keep-generated-files)) and the restored `result` is + rebuilt, its media served from your own origin. + +## Non-streaming video is two calls + +`generateVideo({ stream: true })` runs the whole create → poll → complete +lifecycle inside one call, so persistence sees one run and nothing here applies. + +Without `stream: true` it is two calls, and the video does not exist when the +first one returns: + +```ts +import { generateVideo, getVideoJobStatus } from '@tanstack/ai' +import { + memoryPersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { openaiVideo } from '@tanstack/ai-openai' + +const persistence = memoryPersistence() +const adapter = openaiVideo('sora-2') +const middleware = [withGenerationPersistence(persistence)] +const threadId = 'product-7-launch-clip' + +// Opens the run. Status `running` — there is no video yet. +const { jobId } = await generateVideo({ + adapter, + prompt: 'A cat chasing a dog in a sunny park', + threadId, + middleware, +}) + +// Completes that same run. This is what writes the video and its artifacts. +const status = await getVideoJobStatus({ adapter, jobId, threadId, middleware }) +``` + +Pass the same `threadId` and `middleware` to both. **The `jobId` is the whole +correlation** — the run id is derived from it, so the poll finds the run the +submit opened without you storing anything, even from a different request or +process. There is no run id to thread. + +Until a poll observes a terminal job state the record stays `running`, which is +the truth: a client hydrating that slot sees a generation still in flight. A +submission that fails records a terminal `error` run instead, so a reload shows +the failure rather than an empty slot. + +## Going further + +- [Keep generated files](./keep-generated-files): store the generated bytes so + the media itself survives, not just the record. diff --git a/docs/persistence/id-map.md b/docs/persistence/id-map.md new file mode 100644 index 000000000..36c42c43e --- /dev/null +++ b/docs/persistence/id-map.md @@ -0,0 +1,276 @@ +--- +title: Id Map +id: id-map +description: "The two ids in TanStack AI and what each one means: threadId is the stable key persistence stores and restores by, runId names one execution. What they mean on useChat versus the generation hooks, how to choose a threadId, and when to read runId." +keywords: + - threadId + - runId + - thread id + - run id + - persistence key + - generation scope + - restore after reload + - useChat threadId + - useGenerateImage threadId +--- + +# Id Map + +There are only two ids to know, and mixing them up is what makes persistence look +broken. + +| Id | Names | Lifetime | You provide it | +| --- | --- | --- | --- | +| `threadId` | the thing runs belong to: a conversation, or a generation slot | as long as your app keeps using the same string | yes, from your own domain | +| `runId` | one execution: one streamed answer, one generation job | minted at the start, dead when it ends | no, it is minted for you | + +Persistence stores and restores by `threadId`. `runId` is what you read when you +need to talk to your own server about the execution happening right now. + +```tsx +import { + fetchServerSentEvents, + useChat, + useGenerateImage, +} from '@tanstack/ai-react' + +export function ProductPage({ productId }: { productId: string }) { + // Chat: the thread id names a conversation. + const support = useChat({ + threadId: `support-${productId}`, + connection: fetchServerSentEvents('/api/chat'), + persistence: true, + }) + + // Generation: the thread id names a slot that jobs fill. + const hero = useGenerateImage({ + threadId: `product-${productId}-hero`, + connection: fetchServerSentEvents('/api/generate/image'), + persistence: true, + }) + + // Each hook also reports the id of whatever is running right now. + return ( +

+ chat run {support.runId ?? 'none'}, image job {hero.runId ?? 'none'} +

+ ) +} +``` + +## `threadId`: the key everything is filed under + +A record is written **per `threadId`** and a reload looks it up **by +`threadId`**. If the string is not identical after the reload, there is nothing to +find. Pick it from your own domain, keep it stable, and restore works. Mint a +fresh one on every mount and nothing ever restores. + +### On chat, the thread is the conversation + +Every run in it contributes messages to one growing transcript, stored under the +thread id. Restoring means replaying that transcript. Runs are internal detail the +user never sees, they just see the conversation. + +### On generation, the thread is a slot + +A generation job does not append to anything, it produces one result. So each job +gets its own record, linked to the thread, and restore hands back the **most +recent** job for that thread: its status, its error, its result metadata. +Successive jobs for the same thing (the first attempt, the retry, the regenerate +after a prompt tweak) all land in one slot, and the latest one is what the user is +looking at. + +That is why generation thread ids read like a place in your app rather than a +conversation: + +| The thing on screen | A good `threadId` | +| --- | --- | +| The hero image for a product | `product-${productId}-hero` | +| The start frame of a video | `video-${videoId}-start-frame` | +| The voice-over of a chapter | `chapter-${chapterId}-narration` | +| A transcription of an upload | `upload-${uploadId}-transcript` | +| The support conversation | `chat-${conversationId}` | + +Two rules follow from "one slot, latest job wins": + +- **Two different things need two different ids.** Point a hero-image hook and a + thumbnail hook at the same thread and each restores whatever ran last, in the + other's UI. +- **The same thing keeps its id forever.** A regenerate is a new job in the same + slot, not a new slot. That is what makes a reload land on the newest attempt. + +## `runId`: one execution + +A run is everything between one `RUN_STARTED` and its `RUN_FINISHED`. It is minted +fresh each time and thrown away when it ends. Both kinds of hook report the one +this client has in flight, or `null` when there is none. + +### On chat, a run is one turn + +It changes from turn to turn, and the mapping to what the user did is not +one-to-one: + +- **A whole tool loop is one run.** The model calls a tool, you return a result, + it calls another, it writes the final answer. However many loops the + [agentic cycle](../chat/agentic-cycle) takes, that is a single `runId`. +- **One user message can produce several runs.** Pausing on an + [interrupt](../interrupts/overview) ends the run; resuming continues the same + turn under a **new** `runId`. Approve two tools in sequence and one message has + spanned three runs. While the run sits paused waiting on that approval, nothing + is in flight, so `runId` is `null`. + +So `useChat().runId` answers "what is this client running right now", never +"which message is this". In a [live subscription](../chat/streaming), a run +another client started is not yours to cancel, so it is not reported here. + +```mermaid +flowchart TB + subgraph chat ["useChat, threadId: support-42"] + direction LR + c1["run r1 +tool loop, one turn"] --> c2["run r2 +interrupted"] --> c3["run r3 +the resume of that same turn"] + end + + subgraph gen ["useGenerateImage, threadId: product-7-hero"] + direction LR + g1["job g1 +first attempt"] --> g2["job g2 +retry"] --> g3["job g3 +running"] + end + + chat -. "one transcript, keyed by threadId" .-> cstore["messages store"] + gen -. "one record per job, newest restores" .-> gstore["generationRuns store"] +``` + +### On generation, a run is the job + +One call to `generate(...)` is one job with one `runId`. There is no tool loop and +no interrupt, so the mapping is exactly one-to-one: `runId` is the handle on the +provider work currently in progress. + +That makes it the id you hand your own server, because `stop()` only aborts the +local stream. It does not stop a video render already burning credits on the +provider: + +```tsx +import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react' + +export function VideoPanel({ videoId }: { videoId: string }) { + const video = useGenerateVideo({ + threadId: `video-${videoId}-clip`, + connection: fetchServerSentEvents('/api/generate/video'), + persistence: true, + }) + + async function cancel() { + // Stop the provider job server-side, then drop the local stream. + if (video.runId) { + await fetch(`/api/generate/video/cancel?runId=${video.runId}`, { + method: 'POST', + }) + } + video.stop() + } + + return ( + + ) +} +``` + +The same id is what a durability log is keyed by, so it is also the right thing to +put in a log line when you are chasing one execution across your server. + +## Why restore keys on the thread, not the run + +A page that just reloaded has no idea what the last `runId` was, so it cannot ask +for it. It does know its `threadId`, because your app derived it from a product +id, a route param, a video id. So the client presents the thread, and the store +answers "here is what happened in it, and here is the run still going, if any." + +Only then does the client tail that run's delivery log. Run ids stay essential one +layer down, they are just never the entry point. See +[Threads and runs](../chat/streaming#threads-and-runs) for the protocol anatomy +and [Resumable streams](../resumable-streams/overview) for the log itself. + +## Both sides use the same thread id + +Persistence only works when the client and the server file under the same string. +On the client that is the hook's `threadId`. On the server it is the activity's +`threadId` — for generation the middleware reads it straight off the activity, +so there is nothing to repeat on `withGenerationPersistence`: + +```ts +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input, threadId } = await generationParamsFromRequest('image', request) + + // No scope, nothing to file the job under, so nothing could ever hydrate it. + // Reject instead of inventing an id. + if (threadId === undefined) { + return new Response('`threadId` is required', { status: 400 }) + } + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + threadId, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +The client sends its `threadId` on the wire for you, so the hook side is just the +option. Chat is the same shape: the `threadId` you pass `useChat` is the one +`chatParamsFromRequest` hands your route and `withPersistence` stores under. See +[Chat persistence](./chat-persistence) and +[Generation persistence](./generation-persistence) for the full wiring. + +## When you can skip `threadId` + +Without persistence, `threadId` is optional. The hooks fall back to a generated id +purely to satisfy the protocol, which requires a thread id on the wire. The run +works, it just cannot be found again, which is fine for a one-shot image you show +and forget. + +Turn `persistence` on and `threadId` becomes required, on the hook and on the +activity the middleware wraps — `withGenerationPersistence` throws when neither +the activity nor its own `threadId` **override** supplies one. An app that +cannot name the slot has nothing to restore into. + +## When restore does nothing + +Almost always one of these: + +- **The thread id changed.** A `crypto.randomUUID()` or a `useId()` in it means a + new key on every mount. Log the id on both sides and compare the two strings. +- **The client and server disagree.** The hook files under one id, the middleware + under another. They must match exactly. +- **You keyed on a run id.** Nothing durable is addressable by `runId` alone from + a fresh page load. Restore starts from the thread. +- **Byte storage is off.** For generation, the record never holds media bytes, so + `status` and `error` come back while `result` stays `null`. Add + [byte storage](./keep-generated-files) to get the media back too. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md index 4faf73cc4..7c7d6a2f1 100644 --- a/docs/persistence/internals.md +++ b/docs/persistence/internals.md @@ -50,18 +50,29 @@ stored transcript is loaded and used. ## Generation middleware lifecycle -`withGenerationPersistence(persistence)` records the run: `onStart` creates or -resumes the run record, and `onFinish`, `onError`, and `onAbort` terminalize -it. Durable media storage (artifact metadata plus blob bytes) is a follow-up -feature. - -**Do not treat this as the long-term generation model.** Today it reuses chat -`RunStore` and dual-keys `(runId, threadId)` both to `requestId`. That is a -stopgap: **generation jobs must not fake `threadId = requestId`.** `threadId` -is the shared conversation key (`Scope.threadId`); a generation job's primary -id is `requestId` / `jobId`. The follow-up should introduce a dedicated -generation job store (and later artifact store), not chat `RunStore` / -`MessageStore`. +`withGenerationPersistence(persistence)` records the job across +three points: + +- `onStart` creates or resumes the run record. +- `onFinish` / `onError` / `onAbort` terminalize it. +- A result transform captures the terminal result metadata (ids, urls, never + media bytes) onto the record. + +When `artifacts` and `blobs` are both provided it also persists the generated +media and merges the durable refs onto both the result and the run record. + +Generation uses its own `generationRuns` store (`GenerationRunStore`), never chat's +`runs` / `messages`. A generation has no conversation, so the run is keyed on +its own `runId` (`ctx.runId ?? ctx.requestId`), and `threadId` never becomes the +job's primary identity. + +`threadId` is nonetheless **required**: it is the slot the run is filed under, +and `GenerationRunRecord.threadId` is a required field. The middleware resolves +it as `opts.threadId ?? ctx.threadId` — normally the `threadId` the caller +passed the activity, with the option as an override — and **throws** when +neither supplies one. It is never faked from the request id: a run filed under +an invented scope can never be hydrated by one, so restoring would silently +return nothing forever. ## Composition semantics @@ -95,8 +106,9 @@ removed. Unknown store keys are rejected statically and by runtime validation. Middleware adds entrypoint validation: - chat requires `messages`; rejects `interrupts` without `runs`. -- generation requires `runs`. +- generation requires `generationRuns`. - `reconstructChat` requires `messages`. +- `reconstructGeneration` requires `generationRuns`. The runtime checks are required because JavaScript, configuration loading, and explicitly widened types can bypass static guarantees. diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md new file mode 100644 index 000000000..190311128 --- /dev/null +++ b/docs/persistence/keep-generated-files.md @@ -0,0 +1,246 @@ +--- +title: Keep Generated Files +id: keep-generated-files +--- + +# Keep Generated Files + +Provider URLs for generated media expire. A Sora clip, a batch of images, a long +audio track — the model hands you a URL that stops working after a while, and +once it does the output is gone. To keep the output, save the generated bytes to +your own storage and serve them from your own origin, where they outlive the +provider's link. + +This is a server-side opt-in that layers on **top of** the `generationRuns` store +[Generation persistence](./generation-persistence) already requires. Byte storage +adds two more stores, which must be provided together: + +- an `artifacts` store: the metadata. +- a `blobs` store: the bytes. + +What each choice gets you: + +- **Both provided**: `withGenerationPersistence` writes each generated file's + bytes to the blob store, records an `ArtifactRecord`, and attaches durable + references to the result and the run record. +- **Neither provided**: only the run record is kept. + +`memoryPersistence()` ships all three stores (`generationRuns`, `artifacts`, +`blobs`), so it works out of the box. Any backend that implements `ArtifactStore` +and `BlobStore` (see +[Build your own adapter](./build-your-own-adapter#generation--media-stores)) +works the same way. + +## Serve the stored bytes + +The bytes land under the blob key `artifacts//`. To fetch a +generated file later (to render it, download it, or hand it to another request), +add a `GET` route that reads the artifact back with the `retrieveArtifact` / +`retrieveBlob` helpers and streams it from your own origin: + +```ts group=generation-bytes +// routes/api.generate.image.ts — runs the generation. +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + retrieveArtifact, + retrieveBlob, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input, threadId } = await generationParamsFromRequest( + 'image', + request, + ) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + // Persistence requires the scope these runs are filed under. + if (threadId === undefined) { + return new Response('`threadId` is required', { status: 400 }) + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + threadId, + stream: true, + middleware: [ + withGenerationPersistence(persistence, { + // Stamp the durable serve URL (the artifact route below) onto every + // persisted artifact ref, and rewrite the live result's media field to + // it. Both the live and the restored result then render from your own + // origin instead of the provider's expiring link. + artifactUrl: (ref) => + `/api/generate/image/artifact?id=${ref.artifactId}`, + }), + ], + }) + + return toServerSentEventsResponse(stream) +} +``` + +The serve route is a **separate** route from the generation endpoint. A `GET` on +the generation route is already spoken for by +[Generation persistence](./generation-persistence) — that is where a reloading +client hydrates and where an in-flight run resumes — so the bytes get their own +path, the one `artifactUrl` stamps above: + +```ts group=generation-bytes +// routes/api.generate.image.artifact.ts — serves stored bytes by id. +// +// This is a plain file endpoint: it serves one stored file, it does not resume +// a run or rebuild a conversation. +// +// Security: the id comes from the caller, so this route MUST authorize before +// it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was +// generated under — check that against an identity you derive server-side from +// the session, never from the query string. Without this check, any caller who +// learns or guesses an artifact id can read another user's media. +export async function GET(request: Request) { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + // Replace with your session + ownership check, e.g.: + // const user = await auth(request) + // const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId)) + const owned = true + void request + // 404, not 403 — a distinguishable "exists but forbidden" confirms valid ids. + if (!owned) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) +} +``` + +`memoryPersistence` keeps everything in process memory, which is right for +development and tests; point `generationRuns` / `artifacts` / `blobs` at a durable backend +for production. Control what gets captured with `withGenerationPersistence`'s +`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each +file) options. + +## Choose where the bytes land + +By default an artifact's bytes are written under +`artifacts//`. Pass `storageKey` to put them in your own +folder structure instead — useful when the bucket is shared with the rest of +your app, or when you want media grouped by the thing it belongs to rather than +by the run that produced it: + +```ts group=generation-bytes +const storageKeyOptions = withGenerationPersistence(persistence, { + storageKey: ({ runId, artifactId, role, name }) => + `products/${role}/${runId}-${artifactId}-${name}`, +}) +``` + +Two things worth knowing: + +**The resolved key is recorded on the artifact.** Once the path is arbitrary it +can no longer be recomputed from the record, so it is stored as +`ArtifactRecord.blobKey` and reads resolve through it. Records written before +this existed fall back to the default convention, so adding `storageKey` to an +app with existing artifacts does not orphan them — but it does mean the default +convention can never be changed retroactively. + +**Returning a non-unique key overwrites.** Include `artifactId`, or something +equally unique, unless overwriting is what you want. + +This is server-side only, deliberately. A key supplied by the browser would be a +path-traversal and cross-tenant-write vector. + +## Prompt media referenced by URL + +What gets stored is the **generated output**. When a provider returns an +expiring link, the middleware downloads it and keeps the bytes — that is the +whole point of this page. + +Prompt media is different, and it splits by how you sent it: + +- **base64** (`source: { type: 'data' }`): stored alongside the output, because + the bytes are already in hand. +- **a URL** (`source: { type: 'url' }`): **not fetched**, and no artifact is + recorded for it. + +Two reasons a caller-supplied URL is left alone. Downloading it server-side +would let anyone name an address your server can reach (cloud metadata +endpoints, `localhost` admin services) and then read the response back through +the artifact `GET` route. The copy is also redundant: whoever supplied the URL +already had the media. + +If you do need a durable copy of caller-supplied media — a "paste an image URL" +input box, say — opt in with `allowInputUrl`, which is a predicate rather than a +flag precisely so the check is not optional: + +```ts group=generation-bytes +const inputUrlOptions = withGenerationPersistence(persistence, { + allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'), +}) +``` + +Every artifact fetch, input or output, is bounded three ways: + +- The scheme must be `http:` or `https:`. +- It is aborted after `artifactFetchTimeoutMs` (default 30s). +- It is capped at `maxArtifactBytes` (default 100 MiB) as the body drains. + +Input fetches add two more: a loopback / private / link-local host block, and a +refusal to follow redirects, so a `302` cannot hop somewhere the check never +saw. + +Treat those as a backstop, not the control: a hostname that *resolves* to a +private address still passes a literal-IP check. Keep `allowInputUrl` narrow, +and for stronger isolation inject `artifactFetch` to route downloads through an +egress-restricted proxy that can check the address actually connected to. + +## Wire the durable URL through to the client + +`artifactUrl` is what makes the stored bytes reachable from the client without +any extra plumbing. For each persisted ref it returns the app-origin URL that +serves those bytes (the `GET` route above), and `withGenerationPersistence` does +two things with it: + +- Stamps the URL onto the ref, as `ref.url`. +- Rewrites the live result's media field to the same URL: `result.images[i].url` + for images, `result.url` for a video, `result.audio.url` for audio. + +So the live result already points at your origin, not the provider's expiring +link. + +Those durable refs ride along on `result.artifacts`, and they are what a reload +restores from. In [Generation persistence](./generation-persistence), the +generation hook rebuilds `result` from the persisted refs on mount, resolving +each media field to its durable `ref.url` — so the restored result renders the +same media the live run showed. `result.artifacts` is the whole artifact surface +on the hook: there are no separate top-level artifact fields to read, live or +restored. + +## Where to go next + +- [Generation persistence](./generation-persistence): the run record that + survives a reload or a dropped connection, and the `generationRuns` store that + byte storage builds on. +- [Build your own adapter](./build-your-own-adapter#generation--media-stores): a + custom `ArtifactStore` / `BlobStore` on your own database. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index 73d037a20..8979134d8 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -51,6 +51,48 @@ Run that after the package is installed, not before — Intent scans They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both. +The two layers also key on different ids. A **thread** (`threadId`) is the +conversation — the stable identity that survives reloads and exists on every +device. A **run** (`runId`) is one execution inside it: one streamed answer, +minted fresh each time. A thread accumulates many runs over its life; delivery +durability logs one run, state persistence stores the whole thread: + +```mermaid +flowchart TB + subgraph thread ["One thread — threadId (stable, the conversation)"] + direction LR + run1["run r1 +completed"] --> run2["run r2 +completed"] --> run3["run r3 +running"] + end + + subgraph delivery ["Delivery durability — one byte log per run"] + log["log for r3 +replays the live stream to a reconnecting client"] + end + + subgraph state ["State persistence — durable store per thread"] + store["transcript · run records · interrupts"] + end + + run3 -. "a dropped connection tails" .-> log + thread -- "saved on finish, loaded on mount" --> store +``` + +Run ids are too ephemeral to reconnect by — a reloading client may not know the +current one. Reconnection therefore resolves from the stable `threadId`: the +store answers "does this thread have a live run?" (`findActiveRun`), and only +then does the client tail that run's log. + +`threadId` is the key every durable record is filed under, on chat and on the +generation hooks alike, so getting it right is what makes restore work at all. +[Id map](./id-map) is the short version of both ids: what each means on each side, +how to choose a thread id, and why a reload never asks for a run id. The protocol +anatomy of a thread and its runs is in +[Threads and runs](../chat/streaming#threads-and-runs); what persistence records +about them is in [Chat persistence](./chat-persistence#threads-runs-and-turns). + ## State persistence has two halves Persistence runs on the client, the server, or both. They are independent, and they answer different questions. @@ -265,6 +307,25 @@ the transcript by message id, so nothing is duplicated or lost. Because the reconnect is resolved from the stable `threadId` on the server, a reload and the same thread opened on another device resume the same way. +```mermaid +sequenceDiagram + participant Hook as useChat (persistence: true) + participant Route as GET /api/chat + participant Store as Durable store + participant Log as Delivery log + + Note over Hook: page reloads while a run is streaming + Hook->>Route: ?threadId=support-chat + Route->>Store: reconstructChat — loadThread + findActiveRun + Store-->>Route: messages + activeRun (runId) + Route-->>Hook: transcript + activeRun cursor + Note over Hook: transcript paints + Hook->>Route: ?runId=…&offset=-1 + Route->>Log: resumeServerSentEventsResponse + Log-->>Hook: replay + live tail of the run + Note over Hook: reply finishes in place +``` + Why this wins over the alternatives: - **One source of truth.** History lives on the server, so there is no client/server copy to drift or reconcile. The same conversation opens on any device and survives a server restart. @@ -274,6 +335,23 @@ Why this wins over the alternatives: Client-only persistence can't do multi-device and bloats storage. Caching everything client-side duplicates the source of truth. This combination avoids both: one server-resolved `GET` on mount restores history and rejoins any live run, so a reload and a second device follow the identical path. +## Generation persistence + +Everything above is about chat. Media generation (image, audio, TTS, video, and +transcription) persists as a sibling to it. The generation hooks +(`useGenerateImage`, `useGenerateVideo`, …) take a `persistence` option too, so a +long run's status and result survive a reload or a dropped connection the same +way a conversation does. Theirs is **boolean only**: the record lives on the +server, and the browser caches nothing. + +On the server it is backed by a `generationRuns` store (the generation +counterpart to chat's `runs`), with optional `artifacts` + `blobs` stores to keep +the generated bytes after the provider's URLs expire. + +See [Generation persistence](./generation-persistence) for the record's +lifecycle and the server wiring, and +[Keep generated files](./keep-generated-files) for byte storage. + ## The store contract Server **state** persistence is a set of stores. Middleware activates behavior @@ -286,9 +364,19 @@ from whichever stores are present (with entrypoint requirements — see | `runs` | Run status, timing, errors, and usage. Required on full `ChatPersistence`. | | `interrupts` | Pending, resolved, or cancelled human/tool waits (needs `runs`). | | `metadata` | App and integration key/value state. | +| `generationRuns` | Generation run status, result metadata, and artifact refs, keyed by the run's own `runId`. Required by generation persistence. | +| `artifacts` | Generated-file metadata (needs `blobs`). | +| `blobs` | The generated bytes (needs `artifacts`). | + +The last three are the generation counterpart to the chat stores, used by +`withGenerationPersistence` rather than `withPersistence` — see +[Generation persistence](./generation-persistence). + +Named shapes, covered in [Controls](./controls): -Named shapes: `ChatTranscriptStores` (messages floor), `ChatPersistenceStores` -(all four), `ChatWithInterruptsStores`. See [Controls](./controls). +- `ChatTranscriptStores`: the `messages` floor. +- `ChatPersistenceStores`: all four chat stores. +- `ChatWithInterruptsStores`: `messages` + `runs` + `interrupts`. Need a mutex across instances (cross-worker coordination)? Use `withLocks` and a `LockStore` from `@tanstack/ai/locks`; see [Locks](../advanced/locks). @@ -305,8 +393,11 @@ matching your database loads itself. The full skill list is in ## Where to go next +- [Id map](./id-map): `threadId` vs `runId`, and what each means on chat and on generation. - [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. - [Client persistence](./client-persistence): client- vs server-authoritative modes (`persistence: true`), reload restore, storage backends, and mid-stream rejoin. +- [Generation persistence](./generation-persistence): the same modes for media runs (image, audio, TTS, video, transcription), backed by a `generationRuns` store. +- [Keep generated files](./keep-generated-files): save the generated bytes to your own storage so they outlive the provider's expiring URLs. - [Controls](./controls): compose backends per store and choose which stores to run. - [Build your own adapter](./build-your-own-adapter): a complete SQLite example on the core, plus the store interface reference. - [Resumable streams](../resumable-streams/overview): the delivery-durability layer in full. diff --git a/docs/resumable-streams/advanced.md b/docs/resumable-streams/advanced.md index 30fb1e965..421e74299 100644 --- a/docs/resumable-streams/advanced.md +++ b/docs/resumable-streams/advanced.md @@ -86,17 +86,31 @@ async function attach(runId: string) { All four HTTP adapters (`fetchServerSentEvents`, `fetchHttpStream`, `xhrServerSentEvents`, `xhrHttpStream`) expose `joinRun`. -## Completion, stop, and errors - -The producer awaits `close()` on every in-process exit: normal completion, -`stop()` or response cancellation, provider iteration errors, and caught -server-side durability failures. - -Cancellation and provider failure also append a terminal `RUN_ERROR` before -closing, so a reconnecting or joining client sees a terminal instead of hanging. -If appending that terminal or closing fails, the cause is logged server-side by -default (a joiner only ever sees a generic incomplete error, so the server log -is where the real cause lives). Pass `debug` to route it to your own logger: +## Disconnect, stop, and errors + +A durable run's producer is decoupled from the delivery socket. When the client +disconnects (a page reload, a dropped connection), the response is cancelled but +the run keeps draining into the log to its own terminal, so a reconnect or a +mount-time `joinRun` tails it to completion. This is what makes a run resumable +across a full reload, not just an in-session reconnect. + +A run ends early only on a genuine cancel or a failure: + +- **Cancel** — an `AbortController` you pass to the response as + `abortController` and then abort (from a user Stop button, or by forwarding + `request.signal`, which is an `AbortSignal`, onto a controller of your own). + This stops the producer and appends a terminal `RUN_ERROR`. A bare client + disconnect does not do this; pass a controller when you want a disconnect to + also stop the run. +- **Provider failure** — the model stream throws; the error is appended as a + terminal `RUN_ERROR`. + +Either way the producer awaits `close()` on exit, and a terminal is written +before closing so a reconnecting or joining client sees a terminal instead of +hanging. If appending that terminal or closing fails, the cause is logged +server-side by default (a joiner only ever sees a generic incomplete error, so +the server log is where the real cause lives). Pass `debug` to route it to your +own logger: ```ts import { memoryStream, toServerSentEventsResponse } from '@tanstack/ai' @@ -118,21 +132,22 @@ always emits `RUN_FINISHED`, so this only affects hand-rolled streams. ## memoryStream in production -`memoryStream` is for development and single-process deployments. Two reasons it -does not fit production: +Within a single live process `memoryStream` already survives a client +disconnect: the producer outlives the socket and keeps draining into the log, so +a later reconnect or `joinRun` resumes a still-running run. Two things still keep +it to development and single-process deployments: 1. The log lives in one process's memory, so a reconnect that lands on a different worker finds nothing. -2. The producer and the delivery socket are the same process. A mid-stream - client disconnect aborts the producer and writes a terminal `RUN_ERROR` to - the log, so a later reconnect replays the partial content plus that error - rather than resuming a still-running response. +2. If the process itself dies, the log dies with it, so an interrupted run can + neither resume nor terminalize on its own. A production backend adds a + lease/reaper (see [Process death](#process-death)) that an in-memory process + cannot. Completed runs are evicted after a grace window, so resuming an expired or unknown run fails loudly instead of hanging, and a from-start join to a run that -never produces fails after `firstChunkDeadlineMs`. Live resume of a run that is -still producing after a disconnect needs a backend whose producer outlives the -socket (see [Process death](#process-death)). +never produces fails after `firstChunkDeadlineMs`. Spanning processes (point 1) +is what `durableStream` adds. ## Reconnection bounding diff --git a/docs/resumable-streams/overview.md b/docs/resumable-streams/overview.md index 3fe9067c4..e4868e6f5 100644 --- a/docs/resumable-streams/overview.md +++ b/docs/resumable-streams/overview.md @@ -28,6 +28,10 @@ it survives a reload or reaches another device is a separate layer. For how the two fit together and when to pick each, see [Durability and Persistence](../persistence/overview). +The log is kept per **run** — one `RUN_STARTED` → `RUN_FINISHED` execution, not +a whole conversation. If the thread/run distinction is new, see +[Threads and runs](../chat/streaming#threads-and-runs). + Three steps: pick an adapter, wrap your response with it, add a `GET` handler. ## 1. Pick an adapter diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 536495866..9b4d744f9 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -178,6 +178,19 @@ export default function Header() { Video Generation + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Persistent Generation + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/generation-durability.ts b/examples/ts-react-chat/src/lib/generation-durability.ts new file mode 100644 index 000000000..25e84c801 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-durability.ts @@ -0,0 +1,36 @@ +import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai' + +/** + * Delivery durability for the generation routes — the layer that makes a run + * resumable, alongside the state persistence in `generation-server-store.ts`. + * + * `memoryStream` logs each chunk so a rejoining client replays it instead of + * re-running the model. Routes opt in by passing the adapter as `durability` on + * their `toServerSentEventsResponse`, and by serving + * {@link replayGenerationIfResuming} from a `GET`. + * + * There is no route-side "detach" helper. A durable response already survives a + * client disconnect: cancelling it cancels only the reader, while the run keeps + * draining into the log to completion, so a mount-time `joinRun` tails it to the + * end. The library owns the run's lifetime once `durability` is set. + * + * `memoryStream` keeps logs in a process-global map: development and + * single-process deployments only. Swap it for `durableStream(request, { + * server })` from `@tanstack/ai-durable-stream` in production; nothing else here + * changes. + */ + +/** + * The GET half of `joinRun`: replay a run when the request carries a resume + * offset, otherwise `null` so the route can fall through to whatever else its + * GET serves (the generation routes also answer `reconstructGeneration` there). + * + * The run id rides the `X-Run-Id` header or `?runId`, and the offset the + * `Last-Event-ID` header or `?offset` — ask the adapter via `resumeFrom()` + * rather than sniffing query params. + */ +export function replayGenerationIfResuming(request: Request): Response | null { + const durability = memoryStream(request) + if (durability.resumeFrom() === null) return null + return resumeServerSentEventsResponse({ adapter: durability }) +} diff --git a/examples/ts-react-chat/src/lib/generation-server-persistence.ts b/examples/ts-react-chat/src/lib/generation-server-persistence.ts new file mode 100644 index 000000000..9dcc8e6e4 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-server-persistence.ts @@ -0,0 +1,11 @@ +import { memoryPersistence } from '@tanstack/ai-persistence' + +/** + * Server persistence for the generation demo. `memoryPersistence()` ships the + * three stores generation persistence uses — `generationRuns` (run lifecycle + + * result metadata), plus `artifacts` and `blobs` for durable byte storage — so a + * reload restores the actual generated image, not just its status. The generate + * route and the artifact serve route both import this module so they share one + * store. Point it at a durable backend for production. + */ +export const generationServerPersistence = memoryPersistence() diff --git a/examples/ts-react-chat/src/lib/generation-server-store.ts b/examples/ts-react-chat/src/lib/generation-server-store.ts new file mode 100644 index 000000000..d0db70882 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-server-store.ts @@ -0,0 +1,41 @@ +import { sqlitePersistence } from './sqlite-persistence' + +let instance: ReturnType | undefined + +/** + * Server-side generation persistence for the example app — the counterpart to + * `generation-persistence.ts`, which is the client-driven half. + * + * Backed by the same self-contained `node:sqlite` adapter the persistent-chat + * demo uses (`./sqlite-persistence`), in its own database file. It supplies the + * three stores generation needs: `generationRuns` (the run record + * `reconstructGeneration` reads back) plus `artifacts` + `blobs` (the generated + * bytes, so a restored `result` can actually render its image rather than + * coming back `null`). + * + * Durable on purpose. The POST that records a run and the GET that serves its + * bytes are separate requests, and an in-memory store would also lose every + * artifact whenever Vite re-evaluates this module on HMR — leaving artifact + * URLs already on screen to 404 mid-session. On disk, none of that applies: + * generated images survive an edit, a restart, and a second worker. `.data/` is + * gitignored. + * + * Lazily opened so importing this module never opens the database in a browser + * bundle. + */ +export function generationServerPersistence() { + return (instance ??= sqlitePersistence({ + url: './.data/generation.db', + migrate: true, + })) +} + +/** + * Serve URL for a stored artifact — must match `routes/api.artifacts.ts`. + * + * One route serves every activity's media, so each generation route passes this + * as `artifactUrl` and nothing else has to know how bytes are addressed. + */ +export function artifactServeUrl(artifactId: string): string { + return `/api/artifacts?id=${encodeURIComponent(artifactId)}` +} diff --git a/examples/ts-react-chat/src/lib/server-fns.ts b/examples/ts-react-chat/src/lib/server-fns.ts index 9b8e17223..6e243b397 100644 --- a/examples/ts-react-chat/src/lib/server-fns.ts +++ b/examples/ts-react-chat/src/lib/server-fns.ts @@ -1,4 +1,4 @@ -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import { chat, @@ -8,15 +8,17 @@ import { generateTranscription, generateVideo, getVideoJobStatus, + memoryStream, + replayRunStream, summarize, toServerSentEventsResponse, } from '@tanstack/ai' import { - openaiImage, - openaiSummarize, - openaiText, - openaiVideo, -} from '@tanstack/ai-openai' + getGenerationHydration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { openaiSummarize, openaiText } from '@tanstack/ai-openai' +import { grokImage, grokVideo } from '@tanstack/ai-grok' import type { UIMessage } from '@tanstack/ai' import { InvalidModelOverrideError, @@ -25,6 +27,10 @@ import { buildSpeechAdapter, buildTranscriptionAdapter, } from './server-audio-adapters' +import { + artifactServeUrl, + generationServerPersistence, +} from './generation-server-store' /** * Server-fn error with a stable `code` property clients can switch on. @@ -95,24 +101,43 @@ const AUDIO_PROVIDER_SCHEMA = z ]) .optional() +// ============================================================================= +// Image generation — server-driven persistence (direct + streaming server fns) +// ============================================================================= +// +// Same stores as `/api/generate/image` (`generationServerPersistence` + +// `/api/artifacts`). Direct and streaming server-fn modes have no GET route, so +// the client supplies `hydrateGeneration` / `joinRun` via the companion +// functions below. See docs/persistence/generation-persistence.md. + +const imageGenerateInputSchema = z.object({ + prompt: z.string(), + numberOfImages: z.number().optional(), + size: z.string().optional(), + // Required: the slot successive runs are filed under for hydration. + threadId: z.string().min(1), +}) + +function imagePersistenceMiddleware() { + return withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }) +} + // ============================================================================= // Direct server functions (non-streaming, return the result directly) // ============================================================================= export const generateImageFn = createServerFn({ method: 'POST' }) - .inputValidator( - z.object({ - prompt: z.string(), - numberOfImages: z.number().optional(), - size: z.string().optional(), - }), - ) + .inputValidator(imageGenerateInputSchema) .handler(async ({ data }) => { return generateImage({ - adapter: openaiImage('gpt-image-1'), + adapter: grokImage('grok-imagine-image'), prompt: data.prompt, numberOfImages: data.numberOfImages, size: data.size as any, + threadId: data.threadId, + middleware: [imagePersistenceMiddleware()], }) }) @@ -227,7 +252,7 @@ export const generateVideoFn = createServerFn({ method: 'POST' }) }), ) .handler(async ({ data }) => { - const adapter = openaiVideo('sora-2') + const adapter = grokVideo('grok-imagine-video') // Create the job const { jobId } = await generateVideo({ @@ -270,25 +295,77 @@ export const generateVideoFn = createServerFn({ method: 'POST' }) // ============================================================================= export const generateImageStreamFn = createServerFn({ method: 'POST' }) - .inputValidator( - z.object({ - prompt: z.string(), - numberOfImages: z.number().optional(), - size: z.string().optional(), - }), - ) + .inputValidator(imageGenerateInputSchema) .handler(({ data }) => { - return toServerSentEventsResponse( - generateImage({ - adapter: openaiImage('gpt-image-1'), - prompt: data.prompt, - numberOfImages: data.numberOfImages, - size: data.size as any, - stream: true, - }), + // One run id for the durability log and the run record so join can replay + // exactly the run hydration reports. + const runId = crypto.randomUUID() + const stream = generateImage({ + adapter: grokImage('grok-imagine-image'), + prompt: data.prompt, + numberOfImages: data.numberOfImages, + size: data.size as any, + threadId: data.threadId, + runId, + stream: true, + middleware: [imagePersistenceMiddleware()], + }) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream({ runId }) }, + }) + }) + +/** + * Wire shape for mount hydration. Parsed with Zod so: + * - TanStack Start gets a JSON-serializable return type (no `unknown`) + * - the store's loose `result` is validated before it crosses the wire + */ +const generationHydrationSchema = z.object({ + resumeSnapshot: z + .object({ + schemaVersion: z.literal(1), + resumeState: z + .object({ + threadId: z.string(), + runId: z.string(), + }) + .nullable(), + status: z.enum(['idle', 'running', 'complete', 'error']), + // `z.any()` (not `z.unknown()`) so Start's serializer accepts the values. + result: z.record(z.string(), z.any()).optional(), + error: z + .object({ + message: z.string(), + code: z.string().optional(), + }) + .optional(), + activity: z.string().optional(), + }) + .nullable(), + activeRun: z.object({ runId: z.string() }).nullable(), +}) + +/** Mount hydration for image `persistence: true` over a fetcher (no GET route). */ +export const getImageHydrationFn = createServerFn({ method: 'GET' }) + .inputValidator(z.string().min(1)) + .handler(async ({ data: threadId }) => { + // No auth in this single-user demo — gate on session in a multi-user app. + return generationHydrationSchema.parse( + await getGenerationHydration(generationServerPersistence(), threadId), ) }) +/** + * Rejoin an in-flight image run over a server function (replay + live tail). + * Returns an SSE `Response` — the client parses it the same way + * `generateImageStreamFn` does (see `chunksFromSseResponse` on the page). + */ +export const joinImageRunFn = createServerFn({ method: 'GET' }) + .inputValidator(z.string().min(1)) + .handler(({ data: runId }) => + toServerSentEventsResponse(replayRunStream(memoryStream({ runId }))), + ) + export const generateSpeechStreamFn = createServerFn({ method: 'POST' }) .inputValidator( z.object({ @@ -386,7 +463,7 @@ export const generateVideoStreamFn = createServerFn({ method: 'POST' }) .handler(({ data }) => { return toServerSentEventsResponse( generateVideo({ - adapter: openaiVideo('sora-2'), + adapter: grokVideo('grok-imagine-video'), prompt: data.prompt, size: data.size as any, duration: data.duration, diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts index 3b37f2065..09bf551b4 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts @@ -7,8 +7,9 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { sqlitePersistence } from './sqlite-persistence' -// All four state stores are provided, so nothing is skipped. (Locks are not a -// state store and the suite does not cover them — this backend has no +// All seven stores are provided — the four chat state stores plus +// `generationRuns` + `artifacts` + `blobs` — so nothing is skipped. (Locks are +// not a store and the suite does not cover them — this backend has no // distributed lock primitive, which is a separate `withLocks` concern.) runPersistenceConformance('ts-react-chat example (node:sqlite)', () => sqlitePersistence({ url: ':memory:', migrate: true }), diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.ts index 74f1a1198..2e67be531 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.ts @@ -4,9 +4,11 @@ * `node:sqlite` driver. No ORM, no extra dependencies — this is the whole thing. * * It exists as a worked demonstration of "rolling your own" concrete persistence - * on the core: the four store interfaces (`MessageStore`, `RunStore`, - * `InterruptStore`, `MetadataStore`) are implemented here against raw SQL, and - * the result is a standard `AIPersistence` you hand to `withPersistence(...)`. + * on the core: the four chat state stores (`MessageStore`, `RunStore`, + * `InterruptStore`, `MetadataStore`) and the three generation stores + * (`GenerationRunStore`, `ArtifactStore`, `BlobStore`) are implemented here + * against raw SQL, and the result is a standard `AIPersistence` you hand to + * `withPersistence(...)` and `withGenerationPersistence(...)`. * * The store semantics mirror the reference in-memory backend shipped in * `@tanstack/ai-persistence` (`memory.ts`) exactly — the shared conformance @@ -22,17 +24,39 @@ import { fileURLToPath } from 'node:url' import { DatabaseSync } from 'node:sqlite' import { defineAIPersistence, + defineArtifactStore, + defineBlobStore, + defineGenerationRunStore, defineInterruptStore, defineMessageStore, defineMetadataStore, defineRunStore, } from '@tanstack/ai-persistence' -import type { ModelMessage, TokenUsage } from '@tanstack/ai' import type { - ChatPersistence, + ModelMessage, + PersistedArtifactRef, + TokenUsage, +} from '@tanstack/ai' +import type { + AIPersistence, + ArtifactRecord, + ArtifactStore, + BlobBody, + BlobListOptions, + BlobListPage, + BlobObject, + BlobRecord, + BlobStore, + GenerationRunRecord, + GenerationRunStatus, + GenerationRunStore, InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, RunRecord, RunStatus, + RunStore, } from '@tanstack/ai-persistence' // --------------------------------------------------------------------------- @@ -75,6 +99,47 @@ CREATE TABLE IF NOT EXISTS metadata ( value_json text NOT NULL, PRIMARY KEY (scope, key) ); +CREATE TABLE IF NOT EXISTS generation_runs ( + run_id text PRIMARY KEY NOT NULL, + thread_id text NOT NULL, + activity text NOT NULL, + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error_json text, + result_json text, + artifacts_json text, + usage_json text +); +-- findLatestForThread orders by started_at within a thread. +CREATE INDEX IF NOT EXISTS generation_runs_thread_started + ON generation_runs (thread_id, started_at DESC); +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + blob_key text, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + source_url text, + created_at integer NOT NULL +); +CREATE INDEX IF NOT EXISTS artifacts_run ON artifacts (run_id); +-- The bytes themselves. \`body\` is a BLOB column, so this file IS the object +-- store; a production adapter would keep metadata here and put bytes in S3/R2. +CREATE TABLE IF NOT EXISTS blobs ( + key text PRIMARY KEY NOT NULL, + body blob NOT NULL, + size integer NOT NULL, + etag text NOT NULL, + content_type text, + custom_metadata_json text, + created_at integer NOT NULL, + updated_at integer NOT NULL +); ` // Row shapes as SQLite hands them back (JSON columns are still text here). @@ -103,6 +168,41 @@ interface InterruptRow { interface MetadataRow { value_json: string } +interface GenerationRunRow { + run_id: string + thread_id: string + activity: string + provider: string + model: string + status: string + started_at: number + finished_at: number | null + error_json: string | null + result_json: string | null + artifacts_json: string | null + usage_json: string | null +} +interface ArtifactRow { + artifact_id: string + run_id: string + thread_id: string + blob_key: string | null + name: string + mime_type: string + size: number + source_url: string | null + created_at: number +} +interface BlobRow { + key: string + body: Uint8Array + size: number + etag: string + content_type: string | null + custom_metadata_json: string | null + created_at: number + updated_at: number +} function parseJson(text: string): T { return JSON.parse(text) as T @@ -365,6 +465,390 @@ function createMetadataStore(db: DatabaseSync) { }) } +// --------------------------------------------------------------------------- +// GenerationRunStore — the generation counterpart to RunStore. +// --------------------------------------------------------------------------- +// +// Keyed by its own `runId` (the id the generation activity mints), with +// `thread_id` the stable slot successive runs fill. `thread_id` is NOT NULL: +// `findLatestForThread` is the only query that hydrates a run, so a row without +// one could be written and then never read back. This stays a separate table +// from `runs` rather than a status column on it because a generation run +// carries its own activity/provider/model and its own artifacts. +function mapGenerationRun(row: GenerationRunRow): GenerationRunRecord { + return { + runId: row.run_id, + threadId: row.thread_id, + activity: row.activity, + provider: row.provider, + model: row.model, + status: row.status as GenerationRunStatus, + startedAt: row.started_at, + ...(row.finished_at != null ? { finishedAt: row.finished_at } : {}), + ...(row.error_json != null + ? { error: parseJson(row.error_json) } + : {}), + ...(row.result_json != null + ? { result: parseJson(row.result_json) } + : {}), + ...(row.artifacts_json != null + ? { + artifacts: parseJson>(row.artifacts_json), + } + : {}), + ...(row.usage_json != null + ? { usage: parseJson(row.usage_json) } + : {}), + } +} + +function createGenerationRunStore(db: DatabaseSync) { + const selectStmt = db.prepare( + 'SELECT * FROM generation_runs WHERE run_id = ?', + ) + const insertStmt = db.prepare( + `INSERT INTO generation_runs + (run_id, thread_id, activity, provider, model, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const latestStmt = db.prepare( + `SELECT * FROM generation_runs WHERE thread_id = ? + ORDER BY started_at DESC LIMIT 1`, + ) + return defineGenerationRunStore({ + createOrResume(input) { + // INVARIANT (idempotency): same as the chat RunStore — a second call for + // a runId returns the stored record and mutates nothing. + const existing = selectStmt.get(input.runId) as + | GenerationRunRow + | undefined + if (existing) return Promise.resolve(mapGenerationRun(existing)) + const status: GenerationRunStatus = input.status ?? 'running' + insertStmt.run( + input.runId, + input.threadId, + input.activity, + input.provider, + input.model, + status, + input.startedAt, + ) + const created = selectStmt.get(input.runId) as + | GenerationRunRow + | undefined + return Promise.resolve( + created + ? mapGenerationRun(created) + : { + runId: input.runId, + threadId: input.threadId, + activity: input.activity, + provider: input.provider, + model: input.model, + status, + startedAt: input.startedAt, + }, + ) + }, + update(runId, patch) { + // Same dynamic-SET shape as the chat RunStore; `error`, `result`, + // `artifacts` and `usage` are JSON columns rather than scalars. + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error_json = ?') + params.push(JSON.stringify(patch.error)) + } + if (patch.result !== undefined) { + sets.push('result_json = ?') + params.push(JSON.stringify(patch.result)) + } + if (patch.artifacts !== undefined) { + sets.push('artifacts_json = ?') + params.push(JSON.stringify(patch.artifacts)) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + if (sets.length === 0) return Promise.resolve() + params.push(runId) + db.prepare( + `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, + ).run(...params) + return Promise.resolve() + }, + get(runId) { + const row = selectStmt.get(runId) as GenerationRunRow | undefined + return Promise.resolve(row ? mapGenerationRun(row) : null) + }, + // The most recent run linked to a thread. `reconstructGeneration` calls + // this so a `persistence: true` client hydrates the last generation for its + // thread from the stable thread id alone, with no run id to hand. + findLatestForThread(threadId) { + const row = latestStmt.get(threadId) as GenerationRunRow | undefined + return Promise.resolve(row ? mapGenerationRun(row) : null) + }, + }) +} + +// --------------------------------------------------------------------------- +// ArtifactStore — one metadata row per generated file. +// --------------------------------------------------------------------------- +function mapArtifact(row: ArtifactRow): ArtifactRecord { + return { + artifactId: row.artifact_id, + runId: row.run_id, + threadId: row.thread_id, + ...(row.blob_key != null ? { blobKey: row.blob_key } : {}), + name: row.name, + mimeType: row.mime_type, + size: row.size, + ...(row.source_url != null ? { sourceUrl: row.source_url } : {}), + createdAt: row.created_at, + } +} + +function createArtifactStore(db: DatabaseSync) { + const upsertStmt = db.prepare( + `INSERT INTO artifacts + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, + ) + const selectStmt = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') + const byRunStmt = db.prepare( + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + ) + const deleteStmt = db.prepare('DELETE FROM artifacts WHERE artifact_id = ?') + const deleteForRunStmt = db.prepare('DELETE FROM artifacts WHERE run_id = ?') + return defineArtifactStore({ + save(record) { + // Upsert, not insert-if-absent: unlike an interrupt, re-saving an + // artifact is a correction of its own metadata, never a state rollback. + upsertStmt.run( + record.artifactId, + record.runId, + record.threadId, + record.blobKey ?? null, + record.name, + record.mimeType, + record.size, + record.sourceUrl ?? null, + record.createdAt, + ) + return Promise.resolve() + }, + get(artifactId) { + const row = selectStmt.get(artifactId) as ArtifactRow | undefined + return Promise.resolve(row ? mapArtifact(row) : null) + }, + list(runId) { + const rows: Array = byRunStmt.all(runId) + return Promise.resolve((rows as Array).map(mapArtifact)) + }, + delete(artifactId) { + deleteStmt.run(artifactId) + return Promise.resolve() + }, + deleteForRun(runId) { + deleteForRunStmt.run(runId) + return Promise.resolve() + }, + }) +} + +// --------------------------------------------------------------------------- +// BlobStore — the bytes, in a BLOB column. +// --------------------------------------------------------------------------- +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +async function bytesFromBlobBody(body: BlobBody): Promise { + if (typeof body === 'string') return textEncoder.encode(body) + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) + if (ArrayBuffer.isView(body)) { + return new Uint8Array( + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + ) + } + if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer()) + + // ReadableStream: drain it into one buffer. + const reader = body.getReader() + const chunks: Array = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function mapBlobRecord(row: BlobRow): BlobRecord { + return { + key: row.key, + size: row.size, + etag: row.etag, + ...(row.content_type != null ? { contentType: row.content_type } : {}), + ...(row.custom_metadata_json != null + ? { + customMetadata: parseJson>( + row.custom_metadata_json, + ), + } + : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + return { + ...record, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice()) + controller.close() + }, + }), + arrayBuffer() { + const copy = new ArrayBuffer(bytes.byteLength) + new Uint8Array(copy).set(bytes) + return Promise.resolve(copy) + }, + text: () => Promise.resolve(textDecoder.decode(bytes)), + } +} + +function createBlobStore(db: DatabaseSync) { + const upsertStmt = db.prepare( + `INSERT INTO blobs + (key, body, size, etag, content_type, custom_metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + body = excluded.body, size = excluded.size, etag = excluded.etag, + content_type = excluded.content_type, + custom_metadata_json = excluded.custom_metadata_json, + updated_at = excluded.updated_at`, + ) + const selectStmt = db.prepare('SELECT * FROM blobs WHERE key = ?') + const createdAtStmt = db.prepare('SELECT created_at FROM blobs WHERE key = ?') + const deleteStmt = db.prepare('DELETE FROM blobs WHERE key = ?') + // Prefix match via `substr(...) = ?` rather than LIKE: SQLite's LIKE is + // case-INsensitive for ASCII and treats `%`/`_` as wildcards, and the + // contract says a prefix matches literally and case-sensitively. Letting + // SQLite compute `length(?)` keeps the character count in SQLite's terms + // instead of JS UTF-16 code units. + const PREFIX_WHERE = 'substr(key, 1, length(?)) = ?' + // Etags only have to change when the bytes do; a per-put counter keeps them + // distinct even for two writes inside the same millisecond. + let putSequence = 0 + + return defineBlobStore({ + async put(key, body, options) { + const bytes = await bytesFromBlobBody(body) + const now = Date.now() + const prior = createdAtStmt.get(key) as { created_at: number } | undefined + // First write stamps createdAt; an overwrite keeps it and moves updatedAt. + const createdAt = prior?.created_at ?? now + const etag = `${now}-${putSequence++}` + const contentType = + options?.contentType ?? (body instanceof Blob ? body.type : '') + const record: BlobRecord = { + key, + size: bytes.byteLength, + etag, + ...(contentType ? { contentType } : {}), + ...(options?.customMetadata + ? { customMetadata: { ...options.customMetadata } } + : {}), + createdAt, + updatedAt: now, + } + upsertStmt.run( + key, + bytes, + record.size ?? bytes.byteLength, + etag, + record.contentType ?? null, + record.customMetadata ? JSON.stringify(record.customMetadata) : null, + createdAt, + now, + ) + return record + }, + get(key) { + const row = selectStmt.get(key) as BlobRow | undefined + return Promise.resolve( + row ? blobObject(mapBlobRecord(row), row.body) : null, + ) + }, + head(key) { + const row = selectStmt.get(key) as BlobRow | undefined + return Promise.resolve(row ? mapBlobRecord(row) : null) + }, + delete(key) { + deleteStmt.run(key) + return Promise.resolve() + }, + list(options?: BlobListOptions): Promise { + if (options?.limit === 0) { + return Promise.resolve({ objects: [], truncated: false }) + } + const prefix = options?.prefix ?? '' + const params: Array = [prefix, prefix] + let where = PREFIX_WHERE + if (options?.cursor !== undefined) { + // Keyset paging: strictly-following keys, in the same byte order as the + // sort, so a full scan visits every key exactly once. + where += ' AND key > ?' + params.push(options.cursor) + } + let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` + const limit = options?.limit + // One extra row detects truncation without a second COUNT query. + if (limit !== undefined) { + sql += ' LIMIT ?' + params.push(limit + 1) + } + const selected: Array = db.prepare(sql).all(...params) + const rows = (selected as Array).map(mapBlobRecord) + if (limit !== undefined && rows.length > limit) { + const objects = rows.slice(0, limit) + const cursor = objects.at(-1)?.key + return Promise.resolve({ + objects, + truncated: true, + ...(cursor !== undefined ? { cursor } : {}), + }) + } + return Promise.resolve({ objects: rows, truncated: false }) + }, + }) +} + export interface SqlitePersistenceOptions { /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ url: string @@ -373,20 +857,38 @@ export interface SqlitePersistenceOptions { } /** - * Build a `ChatPersistence` over a `node:sqlite` database. The returned object - * also exposes `close()` to release the file handle. + * Every store this backend provides, spelled out. * - * Provides all four state stores (`messages`, `runs`, `interrupts`, - * `metadata`). Cross-worker coordination is a separate seam — a `LockStore` - * wired with `withLocks`, not a fifth entry in `stores`. + * Parameterize `AIPersistence` rather than leaving it bare: the unparameterized + * type is the all-optional store bag, and `withPersistence` / + * `withGenerationPersistence` reject it because `stores.messages` / + * `stores.generationRuns` are possibly `undefined`. Spelling out all seven is + * what makes one backend serve both middlewares — there is no packaged named + * shape for "chat + generation" because the combination is a product choice. + */ +export type SqliteAIPersistence = AIPersistence<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + generationRuns: GenerationRunStore + artifacts: ArtifactStore + blobs: BlobStore +}> + +/** + * Build an `AIPersistence` over a `node:sqlite` database. The returned object + * also exposes `close()` to release the file handle. * - * Annotate the return as `ChatPersistence`, not bare `AIPersistence`: the - * unparameterized type is the all-optional store bag, and `withPersistence` - * rejects it because `stores.messages` is possibly `undefined`. + * Provides all four state stores (`messages`, `runs`, `interrupts`, `metadata`) + * AND all three generation stores (`generationRuns`, `artifacts`, `blobs`), so + * the same instance backs `withPersistence` and `withGenerationPersistence`. + * Cross-worker coordination is a separate seam — a `LockStore` wired with + * `withLocks`, not an eighth entry in `stores`. */ export function sqlitePersistence( options: SqlitePersistenceOptions, -): ChatPersistence & { close: () => void } { +): SqliteAIPersistence & { close: () => void } { const filename = normalizeSqliteUrl(options.url) ensureParentDirectory(filename) const db = new DatabaseSync(filename) @@ -403,6 +905,9 @@ export function sqlitePersistence( runs: createRunStore(db), interrupts: createInterruptStore(db), metadata: createMetadataStore(db), + generationRuns: createGenerationRunStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), }, }) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 775782871..78ecdfa20 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as GenerationsSummarizeRouteImport } from './routes/generations.s import { Route as GenerationsStructuredOutputRouteImport } from './routes/generations.structured-output' import { Route as GenerationsStructuredChatRouteImport } from './routes/generations.structured-chat' import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' +import { Route as GenerationsPersistentGenerationRouteImport } from './routes/generations.persistent-generation' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' import { Route as ExampleRuntimeContextRouteImport } from './routes/example.runtime-context' @@ -55,12 +56,14 @@ import { Route as ApiInterruptsRouteImport } from './routes/api.interrupts' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' +import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.speech' import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +import { Route as ApiGenerateImageArtifactRouteImport } from './routes/api.generate.image.artifact' const TypesafeToolsRoute = TypesafeToolsRouteImport.update({ id: '/typesafe-tools', @@ -180,6 +183,12 @@ const GenerationsSpeechRoute = GenerationsSpeechRouteImport.update({ path: '/generations/speech', getParentRoute: () => rootRouteImport, } as any) +const GenerationsPersistentGenerationRoute = + GenerationsPersistentGenerationRouteImport.update({ + id: '/generations/persistent-generation', + path: '/generations/persistent-generation', + getParentRoute: () => rootRouteImport, + } as any) const GenerationsImageRoute = GenerationsImageRouteImport.update({ id: '/generations/image', path: '/generations/image', @@ -295,6 +304,11 @@ const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ path: '/api/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ + id: '/api/artifacts', + path: '/api/artifacts', + getParentRoute: () => rootRouteImport, +} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -325,6 +339,12 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ path: '/api/generate/audio', getParentRoute: () => rootRouteImport, } as any) +const ApiGenerateImageArtifactRoute = + ApiGenerateImageArtifactRouteImport.update({ + id: '/artifact', + path: '/artifact', + getParentRoute: () => ApiGenerateImageRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -344,6 +364,7 @@ export interface FileRoutesByFullPath { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -367,6 +388,7 @@ export interface FileRoutesByFullPath { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -374,11 +396,12 @@ export interface FileRoutesByFullPath { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -398,6 +421,7 @@ export interface FileRoutesByTo { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -421,6 +445,7 @@ export interface FileRoutesByTo { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -428,11 +453,12 @@ export interface FileRoutesByTo { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -453,6 +479,7 @@ export interface FileRoutesById { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -476,6 +503,7 @@ export interface FileRoutesById { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -483,11 +511,12 @@ export interface FileRoutesById { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -509,6 +538,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -532,6 +562,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -544,6 +575,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/api/generate/image/artifact' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -563,6 +595,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -586,6 +619,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -598,6 +632,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars' + | '/api/generate/image/artifact' id: | '__root__' | '/' @@ -617,6 +652,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -640,6 +676,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -652,6 +689,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/api/generate/image/artifact' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -672,6 +710,7 @@ export interface RootRouteChildren { ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute TypesafeToolsRoute: typeof TypesafeToolsRoute + ApiArtifactsRoute: typeof ApiArtifactsRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -695,6 +734,7 @@ export interface RootRouteChildren { ExampleRuntimeContextRoute: typeof ExampleRuntimeContextRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute + GenerationsPersistentGenerationRoute: typeof GenerationsPersistentGenerationRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute GenerationsStructuredChatRoute: typeof GenerationsStructuredChatRoute GenerationsStructuredOutputRoute: typeof GenerationsStructuredOutputRoute @@ -702,7 +742,7 @@ export interface RootRouteChildren { GenerationsTranscriptionRoute: typeof GenerationsTranscriptionRoute GenerationsVideoRoute: typeof GenerationsVideoRoute ApiGenerateAudioRoute: typeof ApiGenerateAudioRoute - ApiGenerateImageRoute: typeof ApiGenerateImageRoute + ApiGenerateImageRoute: typeof ApiGenerateImageRouteWithChildren ApiGenerateSpeechRoute: typeof ApiGenerateSpeechRoute ApiGenerateVideoRoute: typeof ApiGenerateVideoRoute ExampleGuitarsGuitarIdRoute: typeof ExampleGuitarsGuitarIdRoute @@ -872,6 +912,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsSpeechRouteImport parentRoute: typeof rootRouteImport } + '/generations/persistent-generation': { + id: '/generations/persistent-generation' + path: '/generations/persistent-generation' + fullPath: '/generations/persistent-generation' + preLoaderRoute: typeof GenerationsPersistentGenerationRouteImport + parentRoute: typeof rootRouteImport + } '/generations/image': { id: '/generations/image' path: '/generations/image' @@ -1033,6 +1080,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/api/artifacts': { + id: '/api/artifacts' + path: '/api/artifacts' + fullPath: '/api/artifacts' + preLoaderRoute: typeof ApiArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -1075,9 +1129,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGenerateAudioRouteImport parentRoute: typeof rootRouteImport } + '/api/generate/image/artifact': { + id: '/api/generate/image/artifact' + path: '/artifact' + fullPath: '/api/generate/image/artifact' + preLoaderRoute: typeof ApiGenerateImageArtifactRouteImport + parentRoute: typeof ApiGenerateImageRoute + } } } +interface ApiGenerateImageRouteChildren { + ApiGenerateImageArtifactRoute: typeof ApiGenerateImageArtifactRoute +} + +const ApiGenerateImageRouteChildren: ApiGenerateImageRouteChildren = { + ApiGenerateImageArtifactRoute: ApiGenerateImageArtifactRoute, +} + +const ApiGenerateImageRouteWithChildren = + ApiGenerateImageRoute._addFileChildren(ApiGenerateImageRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CapabilityDemoRoute: CapabilityDemoRoute, @@ -1096,6 +1168,7 @@ const rootRouteChildren: RootRouteChildren = { ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, TypesafeToolsRoute: TypesafeToolsRoute, + ApiArtifactsRoute: ApiArtifactsRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, @@ -1119,6 +1192,7 @@ const rootRouteChildren: RootRouteChildren = { ExampleRuntimeContextRoute: ExampleRuntimeContextRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, + GenerationsPersistentGenerationRoute: GenerationsPersistentGenerationRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, GenerationsStructuredChatRoute: GenerationsStructuredChatRoute, GenerationsStructuredOutputRoute: GenerationsStructuredOutputRoute, @@ -1126,7 +1200,7 @@ const rootRouteChildren: RootRouteChildren = { GenerationsTranscriptionRoute: GenerationsTranscriptionRoute, GenerationsVideoRoute: GenerationsVideoRoute, ApiGenerateAudioRoute: ApiGenerateAudioRoute, - ApiGenerateImageRoute: ApiGenerateImageRoute, + ApiGenerateImageRoute: ApiGenerateImageRouteWithChildren, ApiGenerateSpeechRoute: ApiGenerateSpeechRoute, ApiGenerateVideoRoute: ApiGenerateVideoRoute, ExampleGuitarsGuitarIdRoute: ExampleGuitarsGuitarIdRoute, diff --git a/examples/ts-react-chat/src/routes/api.artifacts.ts b/examples/ts-react-chat/src/routes/api.artifacts.ts new file mode 100644 index 000000000..b6770c2be --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.artifacts.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from '@tanstack/react-router' +import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' +import { generationServerPersistence } from '../lib/generation-server-store' + +/** + * The one route that serves persisted generation media, for every activity. + * + * `withGenerationPersistence` stores each generated file's bytes in the blob + * store and stamps an app-origin URL onto every artifact ref via `artifactUrl` + * — that URL points here. Because artifacts are addressed by their own id and + * carry their own `mimeType`, nothing about serving them is activity-specific: + * an image, a video, a music clip and a speech track all come back through this + * handler. Keeping it in one place is also what keeps the authorization check + * below in one place. + * + * `artifactServeUrl` in `../lib/generation-server-store` builds the URL, so the + * two stay in step. + */ +export const Route = createFileRoute('/api/artifacts')({ + server: { + handlers: { + GET: async ({ request }) => { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) { + return new Response('missing artifact id', { status: 400 }) + } + + const persistence = generationServerPersistence() + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + // A real multi-user app MUST authorize here before serving: the id + // comes from the caller, and `ArtifactRecord` carries the `threadId` + // to check it against. This demo is single-user, so there is no + // session to check. + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + // Artifact ids are content-addressed by run: the bytes behind an id + // never change, so this is safe to cache hard. `private` because a + // real deployment serves these per-user. + 'cache-control': 'private, max-age=31536000, immutable', + }, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.generate.audio.ts b/examples/ts-react-chat/src/routes/api.generate.audio.ts index ab9820b55..b7d5596f3 100644 --- a/examples/ts-react-chat/src/routes/api.generate.audio.ts +++ b/examples/ts-react-chat/src/routes/api.generate.audio.ts @@ -1,11 +1,25 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateAudio, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateAudio, + generationParamsFromBody, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildAudioAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const AUDIO_PROVIDER_SCHEMA = z .enum([ @@ -64,6 +78,31 @@ export const Route = createFileRoute('/api/generate/audio')({ const { prompt, duration, provider, model } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody('audio', body)) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildAudioAdapter(provider ?? 'gemini-lyria', model) @@ -72,9 +111,26 @@ export const Route = createFileRoute('/api/generate/audio')({ prompt, duration, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Copies the generated audio into our blob store and rewrites the + // result to the shared `/api/artifacts` serve URL, so a restored + // run still plays after the provider's link expires. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: each chunk is logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run itself still ends with the request — an audio + // clip is short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -105,6 +161,15 @@ export const Route = createFileRoute('/api/generate/audio')({ }) } }, + + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // clip still restores after a reload once its delivery log ages out. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts b/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts new file mode 100644 index 000000000..8cd444c9b --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' +import { generationServerPersistence } from '../lib/generation-server-persistence' + +/** + * Serves a persisted generation artifact's bytes by id, straight from the + * `retrieveArtifact` / `retrieveBlob` helpers. `withGenerationPersistence`'s + * `artifactUrl` stamps `?id=` onto each stored image, so a restored + * run renders its media from this route (our own origin) rather than the + * provider's expiring URL. + */ +export const Route = createFileRoute('/api/generate/image/artifact')({ + server: { + handlers: { + GET: async ({ request }) => { + const id = new URL(request.url).searchParams.get('id') + if (!id) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(generationServerPersistence, id) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(generationServerPersistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index 8ec1ed4ed..c427c17e8 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -1,24 +1,102 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiImage } from '@tanstack/ai-openai' +import { + generateImage, + generationParamsFromRequest, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { grokImage } from '@tanstack/ai-grok' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' +/** + * Image generation with SERVER-side persistence — the other half of the + * client-driven adapter in `generation-persistence.ts`. + * + * `withGenerationPersistence` records each run in `stores.generationRuns` and, + * because this backend also has `artifacts` + `blobs`, copies the generated + * bytes out of the provider's expiring URL into our own store. `artifactUrl` + * then stamps an app-origin serve URL onto every ref and rewrites the live + * result to it — so both the live and the restored image render from here. + * + * The stores are SQLite-backed, so a generated image is still there after a + * dev-server restart — reload the page and it renders from the database. + * + * The bytes themselves are served by the shared `/api/artifacts` route, which + * every generation activity here shares. + * + * Chunks are also logged for replay (see `../lib/generation-durability`), so + * the GET does two independent jobs, like the persistent-chat route: a + * `joinRun` delivery replay when the request carries a resume offset, and + * otherwise the `?threadId=` mount hydration that `persistence: true` calls. + */ export const Route = createFileRoute('/api/generate/image')({ server: { handlers: { POST: async ({ request }) => { - const body = await request.json() - const { prompt, size, model, numberOfImages } = body.data + // Carries `threadId` / `runId` off the AG-UI envelope as well as the + // input, so the run record is filed under the scope the client will + // later hydrate by. + const { input, threadId, runId } = await generationParamsFromRequest( + 'image', + request, + ) + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return new Response( + '`threadId` is required — it is the scope this generation is filed under.', + { status: 400 }, + ) + } const stream = generateImage({ - adapter: openaiImage(model ?? 'gpt-image-1'), - prompt, - size, - numberOfImages, + adapter: grokImage('grok-imagine-image'), + prompt: input.prompt, + // `size` is deliberately not forwarded: the generic image input types + // it as `string`, while each adapter narrows it to its own union, and + // this page's UI never sends one. + ...(input.numberOfImages + ? { numberOfImages: input.numberOfImages } + : {}), + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), stream: true, + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a reconnect + // or a mount-time `joinRun` replays instead of re-running the model. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, + + // Two independent jobs, resolved in order: + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`. Pass `authorize` + // here in a multi-user app. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.speech.ts b/examples/ts-react-chat/src/routes/api.generate.speech.ts index d0f4240e6..687e9be36 100644 --- a/examples/ts-react-chat/src/routes/api.generate.speech.ts +++ b/examples/ts-react-chat/src/routes/api.generate.speech.ts @@ -1,11 +1,25 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateSpeech, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateSpeech, + generationParamsFromBody, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildSpeechAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const SPEECH_PROVIDER_SCHEMA = z .enum(['openai', 'gemini', 'fal', 'grok', 'elevenlabs']) @@ -58,6 +72,31 @@ export const Route = createFileRoute('/api/generate/speech')({ const { text, voice, format, provider } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody('tts', body)) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildSpeechAdapter(provider ?? 'openai') @@ -67,9 +106,26 @@ export const Route = createFileRoute('/api/generate/speech')({ voice, format, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Copies the synthesized speech into our blob store and rewrites the + // result to the shared `/api/artifacts` serve URL, so a restored run + // still plays after the provider's link expires. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run still ends with the request — this activity is + // short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -98,6 +154,15 @@ export const Route = createFileRoute('/api/generate/speech')({ }) } }, + + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // clip still restores after a reload once its delivery log ages out. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index 632c822b3..6e7695283 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -1,26 +1,93 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiVideo } from '@tanstack/ai-openai' +import { + generateVideo, + generationParamsFromBody, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { grokVideo } from '@tanstack/ai-grok' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' +/** + * Video generation, durable end to end — the activity that needs it most: a run + * takes minutes, so a refresh mid-job is the normal case rather than the edge. + * + * - STATE: `withGenerationPersistence` records the run and copies the finished + * video into our blob store, and `artifactUrl` rewrites the result to the + * shared `/api/artifacts` route — so it still plays after the provider's link + * expires. + * - DELIVERY + LIFETIME: `memoryStream` logs each chunk for replay, and because + * a durable response is decoupled from its request, a reload cancels only the + * reader — the run keeps polling to completion into the log, and the client's + * `joinRun` on mount tails it to the end. No route-side detachment needed; the + * library owns the run's lifetime once `durability` is set. + * + * The GET does two jobs: `joinRun` delivery replay for an in-flight run, then + * `?threadId=` mount hydration for a finished run whose delivery log aged out. + */ export const Route = createFileRoute('/api/generate/video')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() + // Adapter arguments come straight off the envelope's `data`: `size` and + // `model` are adapter-specific unions that the provider-agnostic video + // input widens to `string`. const { prompt, size, duration, model } = body.data + const { threadId, runId } = generationParamsFromBody('video', body) + + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return new Response( + '`threadId` is required — it is the scope this generation is filed under.', + { status: 400 }, + ) + } const stream = generateVideo({ - adapter: openaiVideo(model ?? 'sora-2'), + adapter: grokVideo(model ?? 'grok-imagine-video'), prompt, size, duration, stream: true, pollingInterval: 3000, maxDuration: 600_000, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) - return toServerSentEventsResponse(stream) + // Durable delivery: the run survives a reload and keeps polling to + // completion into the log; a mount-time `joinRun` tails it to the end. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, + + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset — + // re-attach to a run still in flight from a previous request. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // video (aged out of the delivery log) still restores after a reload. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.summarize.ts b/examples/ts-react-chat/src/routes/api.summarize.ts index 5ea4ef994..cd121ee48 100644 --- a/examples/ts-react-chat/src/routes/api.summarize.ts +++ b/examples/ts-react-chat/src/routes/api.summarize.ts @@ -1,24 +1,87 @@ import { createFileRoute } from '@tanstack/react-router' -import { summarize, toServerSentEventsResponse } from '@tanstack/ai' +import { + memoryStream, + summarize, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { openaiSummarize } from '@tanstack/ai-openai' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { generationServerPersistence } from '../lib/generation-server-store' +/** + * Text summarization with DELIVERY durability, so a mid-run reload rejoins and + * tails the stream to completion — the same resumability the media routes get. + * + * Summarize produces text, not media, so there are no artifacts to store: the + * run record alone is enough for `persistence: true` to repaint the last summary + * after a reload, and `reconstructGeneration` serves it from the GET below. + * + * A *mid-run* reload additionally needs the delivery log: + * `memoryStream` records the chunks, and the run's `runId` is threaded into + * `summarize()` so the emitted `RUN_STARTED` is keyed by the same id the client + * rejoins with. Without that alignment the client's `joinRun` GET would tail a + * different (empty) log and fast-fail. + */ export const Route = createFileRoute('/api/summarize')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() const { text, maxLength, style, model } = body.data + // The AG-UI envelope carries the run identity alongside `data`. It also + // rides the `X-Run-Id` header (which `memoryStream` keys the log by), so + // threading `runId` into `summarize()` keeps RUN_STARTED and the log in + // sync. + const threadId = + typeof body.threadId === 'string' ? body.threadId : undefined + const runId = typeof body.runId === 'string' ? body.runId : undefined + + // Persistence needs the scope named. Reject a request without one + // rather than filing the run somewhere no client could hydrate by. + if (!threadId) { + return new Response( + '`threadId` is required — it is the scope this summary is filed under.', + { status: 400 }, + ) + } const stream = summarize({ - adapter: openaiSummarize(model ?? 'gpt-4o-mini'), + adapter: openaiSummarize(model ?? 'gpt-5.5'), text, maxLength, style, stream: true, + ...(runId ? { runId } : {}), + threadId, + // Writes the run record. Summarize has no media, so no artifact + // stores are needed — the record alone is what mount hydration + // repaints the last summary from. + middleware: [ + withGenerationPersistence(generationServerPersistence()), + ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a mount-time + // `joinRun` replays/tails instead of failing. The producer is decoupled + // from this response by the library, so a reload keeps it draining to + // the log until the summary finishes. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, + + // Two jobs, resolved in order: + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, so a finished summary restores after a reload even + // once its delivery log has aged out. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.transcribe.ts b/examples/ts-react-chat/src/routes/api.transcribe.ts index a26800547..bf8d84479 100644 --- a/examples/ts-react-chat/src/routes/api.transcribe.ts +++ b/examples/ts-react-chat/src/routes/api.transcribe.ts @@ -1,11 +1,25 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateTranscription, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateTranscription, + generationParamsFromBody, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildTranscriptionAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const TRANSCRIPTION_PROVIDER_SCHEMA = z .enum(['openai', 'openai-diarize', 'fal', 'grok', 'elevenlabs']) @@ -64,6 +78,34 @@ export const Route = createFileRoute('/api/transcribe')({ const { audio, language, responseFormat, modelOptions, provider } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody( + 'transcription', + body, + )) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildTranscriptionAdapter(provider ?? 'openai') @@ -74,9 +116,26 @@ export const Route = createFileRoute('/api/transcribe')({ responseFormat, modelOptions, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Transcription produces text, not media — what gets persisted here + // is the run record plus the INPUT audio as an artifact, so a + // restored run still shows what was transcribed. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run still ends with the request — this activity is + // short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -105,6 +164,15 @@ export const Route = createFileRoute('/api/transcribe')({ }) } }, + + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // transcription still restores after a reload once its log ages out. + GET: async ({ request }) => + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/generation-hooks.tsx b/examples/ts-react-chat/src/routes/generation-hooks.tsx index a4913116b..954d0a770 100644 --- a/examples/ts-react-chat/src/routes/generation-hooks.tsx +++ b/examples/ts-react-chat/src/routes/generation-hooks.tsx @@ -153,33 +153,39 @@ function GenerationHooksPage() { const [audioDuration, setAudioDuration] = useState(3) const image = useGenerateImage({ - id: 'generation-hooks:useGenerateImage', + threadId: 'generation-hooks:useGenerateImage', connection: imageConnection, + persistence: true, }) const audio = useGenerateAudio({ - id: 'generation-hooks:useGenerateAudio', + threadId: 'generation-hooks:useGenerateAudio', connection: audioConnection, + persistence: true, }) const speech = useGenerateSpeech({ - id: 'generation-hooks:useGenerateSpeech', + threadId: 'generation-hooks:useGenerateSpeech', connection: speechConnection, + persistence: true, }) const transcription = useTranscription({ - id: 'generation-hooks:useTranscription', + threadId: 'generation-hooks:useTranscription', connection: transcriptionConnection, + persistence: true, }) const summarize = useSummarize({ - id: 'generation-hooks:useSummarize', + threadId: 'generation-hooks:useSummarize', connection: summarizeConnection, + persistence: true, }) const video = useGenerateVideo({ - id: 'generation-hooks:useGenerateVideo', + threadId: 'generation-hooks:useGenerateVideo', connection: videoConnection, + persistence: true, }) const loadingCount = [ @@ -191,20 +197,30 @@ function GenerationHooksPage() { video.isLoading, ].filter(Boolean).length - const runImage = () => image.generate({ prompt, numberOfImages: imageCount }) - const runAudio = () => audio.generate({ prompt, duration: audioDuration }) - const runSpeech = () => speech.generate({ text: speechText, voice: 'local' }) - const runTranscription = () => - transcription.generate({ + const runImage = () => { + return image.generate({ prompt, numberOfImages: imageCount }) + } + const runAudio = () => { + return audio.generate({ prompt, duration: audioDuration }) + } + const runSpeech = () => { + return speech.generate({ text: speechText, voice: 'local' }) + } + const runTranscription = () => { + return transcription.generate({ audio: SAMPLE_TRANSCRIPTION_AUDIO, language: 'en', }) - const runSummarize = () => - summarize.generate({ + } + const runSummarize = () => { + return summarize.generate({ text: summaryText, style: 'bullet-points', }) - const runVideo = () => video.generate({ prompt }) + } + const runVideo = () => { + return video.generate({ prompt }) + } const runAll = async () => { await Promise.all([ diff --git a/examples/ts-react-chat/src/routes/generations.audio.tsx b/examples/ts-react-chat/src/routes/generations.audio.tsx index 757278e73..9ade3444f 100644 --- a/examples/ts-react-chat/src/routes/generations.audio.tsx +++ b/examples/ts-react-chat/src/routes/generations.audio.tsx @@ -13,6 +13,8 @@ import { type Mode = 'hooks' | 'server-fn' +// Persist each variant's lightweight resume snapshot across reloads. + interface AudioOutput { url: string contentType?: string @@ -74,16 +76,20 @@ function AudioGenerationForm({ const hookOptions = useMemo(() => { if (mode === 'hooks') { return { + threadId: `audio:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/generate/audio'), body: { provider: config.id, model: selectedModel }, + persistence: true, onResult: toAudioOutput, } } return { + threadId: `audio:${mode}:${config.id}`, fetcher: (input: { prompt: string; duration?: number }) => generateAudioFn({ data: { ...input, provider: config.id, model: selectedModel }, }), + persistence: true, onResult: toAudioOutput, } }, [mode, config.id, selectedModel]) diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 044570498..23faca24e 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -4,14 +4,79 @@ import { useGenerateImage } from '@tanstack/ai-react' import type { UseGenerateImageReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' -import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' +import type { StreamChunk } from '@tanstack/ai' +import { + generateImageFn, + generateImageStreamFn, + getImageHydrationFn, + joinImageRunFn, +} from '../lib/server-fns' + +// This page shows server-driven persistence (`persistence: true`) on every +// transport. Streaming uses the HTTP connection's built-in GET hydrate/join. +// Direct and Server Fn have no GET route, so they pass `hydrateGeneration` / +// `joinRun` as options (backed by companion server functions). All three write +// run records + image bytes into the same SQLite stores, and restored images +// render from `/api/artifacts`. + +/** + * Parse an SSE `Response` from a server function into StreamChunks — the same + * shape `GenerationClient` uses when a fetcher returns `toServerSentEventsResponse`. + * Needed for `joinRun`, which expects an `AsyncIterable` rather than a Response. + */ +async function* chunksFromSseResponse( + response: Response, + signal?: AbortSignal, +): AsyncGenerator { + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status} ${response.statusText}`, + ) + } + const reader = response.body?.getReader() + if (!reader) return + const decoder = new TextDecoder() + let buffer = '' + try { + while (!signal?.aborted) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data:')) continue + const raw = line.slice(5) + const data = raw.startsWith(' ') ? raw.slice(1) : raw + if (!data || data === '[DONE]') continue + try { + yield JSON.parse(data) as StreamChunk + } catch { + // skip malformed chunk + } + } + } + } finally { + reader.releaseLock() + } +} + +const IMAGE_THREAD = { + streaming: 'image:streaming', + direct: 'image:direct', + 'server-fn': 'image:server-fn', +} as const function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') const [numberOfImages, setNumberOfImages] = useState(1) const hookReturn = useGenerateImage({ + threadId: IMAGE_THREAD.streaming, connection: fetchServerSentEvents('/api/generate/image'), + // Server-driven: the browser caches nothing. On mount the hook issues a + // `GET /api/generate/image?threadId=…`, answered by `reconstructGeneration`. + persistence: true, }) return ( @@ -28,12 +93,22 @@ function StreamingImageGeneration() { function DirectImageGeneration() { const [prompt, setPrompt] = useState('') const [numberOfImages, setNumberOfImages] = useState(1) + const threadId = IMAGE_THREAD.direct const hookReturn = useGenerateImage({ + threadId, fetcher: (input) => generateImageFn({ - data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, + data: { + ...input, + prompt: resolveMediaPrompt(input.prompt).text, + threadId, + }, }), + // No GET route on a server function — supply the hydrate handler yourself. + hydrateGeneration: (id) => getImageHydrationFn({ data: id }), + // Direct is non-streaming, so there is no in-flight stream to rejoin. + persistence: true, }) return ( @@ -50,12 +125,24 @@ function DirectImageGeneration() { function ServerFnImageGeneration() { const [prompt, setPrompt] = useState('') const [numberOfImages, setNumberOfImages] = useState(1) + const threadId = IMAGE_THREAD['server-fn'] const hookReturn = useGenerateImage({ + threadId, fetcher: (input) => generateImageStreamFn({ - data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, + data: { + ...input, + prompt: resolveMediaPrompt(input.prompt).text, + threadId, + }, }), + hydrateGeneration: (id) => getImageHydrationFn({ data: id }), + joinRun: async function* (runId, signal) { + const response = await joinImageRunFn({ data: runId }) + yield* chunksFromSseResponse(response as Response, signal) + }, + persistence: true, }) return ( @@ -181,7 +268,8 @@ function ImageGenerationPage() {

Image Generation

- Generate images using OpenAI's image models + Generate images using xAI's Grok Imagine models. All three + modes persist runs + image bytes server-side — reload to restore.

diff --git a/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx b/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx new file mode 100644 index 000000000..c3645b9c2 --- /dev/null +++ b/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx @@ -0,0 +1,427 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + FileAudio, + FileText, + Image, + Mic, + Music, + Play, + RotateCcw, + Video, +} from 'lucide-react' +import { + useGenerateAudio, + useGenerateImage, + useGenerateSpeech, + useGenerateVideo, + useSummarize, + useTranscription, +} from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' + +/** + * Every generation surface, persisted, on one page — the manual-verification + * harness for generation persistence. + * + * The five MEDIA activities (image, audio, speech, transcription, video) are + * **server-driven**: `persistence: true` + a `connection`. The browser caches + * nothing; the server records each run and copies the generated bytes into its + * own store (see `../lib/generation-server-store` and the `/api/generate/*` + * routes). On mount the hook issues a `GET …?threadId=…`, answered by + * `reconstructGeneration`, so a full reload restores the result — and if the + * run is still in flight, the hook rejoins its stream and finishes it in place. + * + * `summarize` produces text rather than media, so it stores no artifacts, but it + * persists the same way: the summary text rides on the run record, and the + * mount-time hydration repaints it. + * + * Each hook has a stable `threadId` — the scope the run is filed under and the + * key hydration looks it up by, so it must not change across reloads. + */ + +function PersistentGenerationPage() { + const [imagePrompt, setImagePrompt] = useState('a lighthouse at dusk') + const [audioPrompt, setAudioPrompt] = useState('a calm lofi beat') + const [speechText, setSpeechText] = useState( + 'Persistence keeps this generation across a reload.', + ) + const [summaryText, setSummaryText] = useState(SAMPLE_TEXT) + const [videoPrompt, setVideoPrompt] = useState('a paper plane over a city') + + // --- Media: server-driven (persistence: true + connection) --------------- + + const image = useGenerateImage({ + threadId: 'persistent-generation:image', + connection: fetchServerSentEvents('/api/generate/image'), + persistence: true, + }) + + const audio = useGenerateAudio({ + threadId: 'persistent-generation:audio', + connection: fetchServerSentEvents('/api/generate/audio'), + // Fal-backed (FAL_KEY): gemini-lyria needs a Gemini key this example's + // .env doesn't ship. Swap the provider if you have a different key set. + body: { provider: 'fal-audio', model: 'fal-ai/elevenlabs/music' }, + persistence: true, + }) + + const speech = useGenerateSpeech({ + threadId: 'persistent-generation:speech', + connection: fetchServerSentEvents('/api/generate/speech'), + body: { provider: 'openai' }, + persistence: true, + }) + + const transcription = useTranscription({ + threadId: 'persistent-generation:transcription', + connection: fetchServerSentEvents('/api/transcribe'), + body: { provider: 'openai' }, + persistence: true, + }) + + const video = useGenerateVideo({ + threadId: 'persistent-generation:video', + connection: fetchServerSentEvents('/api/generate/video'), + persistence: true, + }) + + // --- Text: client-driven (summary text lives in the snapshot) ------------ + + const summarize = useSummarize({ + threadId: 'persistent-generation:summarize', + connection: fetchServerSentEvents('/api/summarize'), + body: { model: 'gpt-5.5' }, + persistence: true, + }) + + const handleTranscribe = async ( + e: React.ChangeEvent, + ): Promise => { + const file = e.target.files?.[0] + if (!file) return + const buffer = await file.arrayBuffer() + const base64 = btoa( + new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''), + ) + await transcription.generate({ + audio: `data:${file.type};base64,${base64}`, + language: 'en', + }) + e.target.value = '' + } + + return ( +
+
+
+

+ Generation persistence +

+

+ Persistent Generation +

+

+ Every generation surface, persisted. Start any run and reload the + page: a run still in flight reconnects and finishes; a finished run + restores from the server. Summarize restores from localStorage. +

+
+ +
+ void image.generate({ prompt: imagePrompt })} + onReset={image.reset} + > + setImagePrompt(e.target.value)} + disabled={image.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> +
+ {image.result?.images.map((img, i) => ( + {img.revisedPrompt + ))} +
+
+ + void video.generate({ prompt: videoPrompt })} + onReset={video.reset} + onStop={video.stop} + > + setVideoPrompt(e.target.value)} + disabled={video.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> +
+ job {video.jobId ?? 'none'} + status {video.videoStatus?.status ?? 'idle'} + + progress{' '} + {video.videoStatus?.progress == null + ? '0%' + : `${video.videoStatus.progress}%`} + +
+ {video.result?.url && ( +
+ + void audio.generate({ prompt: audioPrompt })} + onReset={audio.reset} + > + setAudioPrompt(e.target.value)} + disabled={audio.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> + {audio.result?.audio.url && ( + + + void speech.generate({ text: speechText })} + onReset={speech.reset} + > +