Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1fe3929
feat(persistence): client-side generation persistence
AlemTuzlak Jul 23, 2026
dddb4e2
docs(persistence): drop generic from generation snapshot store example
AlemTuzlak Jul 23, 2026
f3b35f2
refactor(persistence): align generation persistence API with chat
AlemTuzlak Jul 23, 2026
b031f60
refactor(persistence): infer generation store type via GenerationPers…
AlemTuzlak Jul 23, 2026
afbd8bd
refactor(persistence): value-agnostic web-storage adapter defaults
AlemTuzlak Jul 23, 2026
0e91e42
docs(persistence): fix stale generation-persistence delivery guidance
AlemTuzlak Jul 23, 2026
fb054da
docs(persistence): rewrite generation-persistence for clarity + when-…
AlemTuzlak Jul 23, 2026
765bcfd
docs(persistence): drop redundant storage-adapter comment
AlemTuzlak Jul 23, 2026
549b01d
fix(persistence): generation snapshot lifecycle, hydration, StrictMod…
tombeckenham Jul 28, 2026
ba659af
fix(persistence): docs, example, changeset, React hooks, and real tes…
tombeckenham Jul 28, 2026
81b5b74
test(persistence): E2E reload-and-rehydrate spec for generation snaps…
tombeckenham Jul 28, 2026
70bf9b6
docs(skills): cover generation resume snapshots in client-persistence…
tombeckenham Jul 28, 2026
18a329f
fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hyd…
tombeckenham Jul 28, 2026
840e49f
ci: apply automated fixes
autofix-ci[bot] Jul 28, 2026
fbc3dc3
refactor(ai-react): thread TInput through UseGenerationReturn, drop g…
tombeckenham Jul 28, 2026
7cdf124
refactor: thread TInput through generation return types in solid/vue/…
tombeckenham Jul 28, 2026
a1fb436
refactor(persistence): restore typed storage-adapter defaults (drop T…
tombeckenham Jul 28, 2026
e3fd042
feat(persistence): durable generation media-byte storage
AlemTuzlak Jul 23, 2026
0d15167
docs(persistence): document generation media-byte storage (artifacts …
AlemTuzlak Jul 23, 2026
fc9a296
feat(ai-persistence): retrieveArtifact / retrieveBlob serve helpers
AlemTuzlak Jul 23, 2026
4fc4605
docs(persistence): clarify the artifact serve route fetches files, no…
AlemTuzlak Jul 23, 2026
a1f39a2
fix(persistence): reconcile generation media bytes with contract-only…
tombeckenham Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@tanstack/ai': minor
'@tanstack/ai-utils': 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
---

Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes.

**Media byte storage.** `withGenerationPersistence` now persists the generated bytes when the persistence backend provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store. As an image/audio/speech/transcription run finishes, the middleware writes each artifact's bytes to the blob store (key `artifacts/<runId>/<artifactId>`), records an `ArtifactRecord`, attaches `PersistedArtifactRef`s to the result, and emits a `generation:artifacts` event so the client records them. Extraction is customizable via `extractArtifacts` / `nameArtifact`. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options and run their result through middleware result transforms. `@tanstack/ai-utils` adds `base64ToUint8Array`. To serve a stored artifact, `@tanstack/ai-persistence` exports `retrieveArtifact(persistence, id)` and `retrieveBlob(persistence, idOrRecord)` (plus `artifactBlobKey`).

Add client-side generation persistence: a lightweight, read-only resume snapshot for media generation activities.

Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus `pendingArtifacts` / `resultArtifacts`, which stay empty until the server-side artifact pipeline ships in a follow-up).

As a run streams, the client builds a `GenerationResumeSnapshot` β€” run identity, status, errors, and result metadata, but **never** the generated media bytes β€” and writes it to the adapter under the key `generation:<id>`, skipping writes with no material change. On construction the client reads the snapshot back (validated with the new `parseGenerationResumeSnapshot`) unless an explicit `initialResumeSnapshot` seed is provided, so the last run's outcome survives a reload. `stop()` and transport-level failures record terminal snapshots, and `reset()` clears the persisted record. The snapshot never exposes a `resume()` action and never restarts provider work β€” generation still only begins when `generate(...)` is called.

The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too β€” inferred contextually when written inline; a standalone generation store takes an explicit `localStoragePersistence<GenerationResumeSnapshot>()` type argument. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`.
5 changes: 5 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@
"addedAt": "2026-07-22",
"updatedAt": "2026-07-27"
},
{
"label": "Generation Persistence",
"to": "persistence/generation-persistence",
"addedAt": "2026-07-28"
},
{
"label": "Controls",
"to": "persistence/controls",
Expand Down
285 changes: 285 additions & 0 deletions docs/persistence/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
---
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 track of.
Generation persistence keeps a small record of each run so your app can pick
things back up.

It helps with three things:

- **After a reload**, show what the last run was: whether it finished, what
failed, and metadata like the result id or video job id. This is a small
snapshot kept in browser storage and read back automatically on mount.
- **Keep the generated files.** On the server, save the generated bytes to your
own storage so they outlive the provider's expiring URLs.
- **While a run is still streaming**, let a dropped connection re-attach to it
instead of starting over. This reuses the same resumable streams the chat
client uses.

## When to use it

Use it when a run is long enough that a reload or a dropped connection actually
matters, or when you need to keep the output: video, batch images, long audio,
transcription of a big file. For a quick one-shot image you show and forget, you
can skip it.

The browser snapshot never holds the generated bytes, only run identity, status,
errors, and references to the output. Storing the bytes themselves is a
server-side opt-in, shown in [Store the generated bytes](#store-the-generated-bytes).

## Create the server endpoint

Record each run in a store, and wrap the stream so a reload can re-attach to it:

```ts group=generation-persistence
import {
generateImage,
generationParamsFromRequest,
memoryStream,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import { withGenerationPersistence } from '@tanstack/ai-persistence'
import { sqlitePersistence } from './sqlite-persistence'

const persistence = sqlitePersistence({
url: 'file:.tanstack-ai/generation.sqlite',
migrate: true,
})

export async function POST(request: Request) {
const durability = memoryStream(request)
const { input } = await generationParamsFromRequest('image', request)

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,
stream: true,
middleware: [withGenerationPersistence(persistence)],
})

// withGenerationPersistence records the run's status and usage.
// durability records the stream so a reload can re-attach to it.
return toServerSentEventsResponse(stream, {
durability: { adapter: durability },
})
}

export async function GET(request: Request) {
// Replays an in-flight run from the durability log. No provider call here.
return resumeServerSentEventsResponse({ adapter: memoryStream(request) })
}
```

Use the matching request kind for audio, TTS, video, or transcription.
`./sqlite-persistence` is the hand-rolled adapter from
[Build your own adapter](./build-your-own-adapter) β€” any adapter with a `runs`
store works. `withGenerationPersistence` requires a `runs` store and records
each run in it, keyed by the request id it generates for the run. In
production, swap `memoryStream` for `durableStream` from
`@tanstack/ai-durable-stream`, where requests span processes.

Keep run ids unique across chat and generation when they share a backend,
because `RunStore` is keyed by `runId`.

## Store the generated bytes

Provider URLs for generated media expire. To keep the output, give your
persistence backend an `artifacts` store (metadata) and a `blobs` store (the
bytes). When both are present, `withGenerationPersistence` writes each generated
file's bytes to the blob store, records an `ArtifactRecord`, and attaches
durable references to the result. `memoryPersistence()` ships both stores, so
it works out of the box; any backend that implements `ArtifactStore` and
`BlobStore` works the same way.

The bytes land under the blob key `artifacts/<runId>/<artifactId>`. 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 by its id and streams the bytes
from your own origin. This is a plain file endpoint: it serves one stored file
and nothing more. It does not resume a run or rebuild a conversation.

```ts group=generation-bytes
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, runId } =
await generationParamsFromRequest('image', request)

if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}

const stream = generateImage({
...(threadId ? { threadId } : {}),
...(runId ? { runId } : {}),
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
stream: true,
middleware: [withGenerationPersistence(persistence)],
})

return toServerSentEventsResponse(stream)
}

// Serve a stored artifact's bytes by id.
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 })

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 `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.

### Fetching artifacts later

Two pieces cooperate, and neither one rebuilds a chat:

- The run **remembers which files it produced**. Each reference carries an
`artifactId` and shows up on the generation hook as `resultArtifacts` (and
`pendingArtifacts` while streaming). The client snapshot keeps these across a
reload, and you can also store them in your own database.
- The **`GET` route turns an `artifactId` into bytes**. Point an `<img>` `src`,
a download link, or a later request at `/api/generate/image?id=<artifactId>`
and it streams the stored file.

So a page that generated an image yesterday can show it today: take the
`artifactId` you kept and hit the serve route. That is the whole loop, one id in,
one file out. Rebuilding a conversation's stored messages is a separate concern
handled by the chat `reconstructChat` helper, not by anything here.

## Show the last run after a reload

Pass a storage adapter as `persistence`, and give the hook a stable `id`. The
client writes a snapshot as the run streams and reads it back (validated) when
the component mounts:

```tsx
import { localStoragePersistence } from '@tanstack/ai-client'
import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'
import type { GenerationResumeSnapshot } from '@tanstack/ai-react'

// The explicit type argument is only needed for a standalone store like this
// one; written inline in the hook options, the value type is inferred.
const snapshots = localStoragePersistence<GenerationResumeSnapshot>({
keyPrefix: 'my-app:',
})

export function HeroImageGenerator() {
const image = useGenerateImage({
id: 'hero-image',
connection: fetchServerSentEvents('/api/generate/image'),
persistence: snapshots,
})

return (
<section>
<button
disabled={image.isLoading}
onClick={() =>
void image.generate({ prompt: 'A glass cabin in a pine forest' })
}
>
Generate
</button>

{image.resumeState ? (
<p>Run {image.resumeState.runId} is streaming…</p>
) : null}
{image.resumeSnapshot?.status === 'complete' ? (
<p>Last run finished{image.resumeSnapshot.result?.id ? ` (${image.resumeSnapshot.result.id})` : ''}.</p>
) : null}
{image.resumeSnapshot?.error ? (
<p>Last run failed: {image.resumeSnapshot.error.message}</p>
) : null}
</section>
)
}
```

A few things to know:

- **`resumeSnapshot`** is the whole record: `status` (`idle` / `running` /
`complete` / `error`), an `error` if the run failed, and result metadata.
After a reload it holds the last run's outcome.
- **`resumeState`** is non-null only while a run is in flight β€” it is the
identity (`threadId` / `runId`) of the streaming run, and it is cleared when
the run ends. Use `resumeSnapshot`, not `resumeState`, to display a finished
run.
- **The `id` is the storage key.** The snapshot is written under
`generation:<id>` (plus the adapter's `keyPrefix`), so a stable `id` is what
makes the record findable after a reload. Omitting `id` generates a fresh
key per mount. The `generation:` segment also keeps a chat client with the
same id from colliding with the generation record.
- `stop()` marks the persisted record no longer resumable, and `reset()`
deletes it.
- The snapshot never triggers work. A generation starts only when you call
`generate(...)`.

If your app manages storage itself (custom backends, SSR-provided state), read
the value yourself and pass it as `initialResumeSnapshot` β€” that skips the
automatic read. Validate untrusted values with `parseGenerationResumeSnapshot`
from `@tanstack/ai-client`.

## Reconnect to a run that is still streaming

The server endpoint above already wires this: a `durability` adapter on
`toServerSentEventsResponse`, plus a `GET` handler that replays the run from the
log. On the client there is nothing to add. A connection dropped mid-generation
re-attaches on its own through `fetchServerSentEvents` or `fetchHttpStream`, the
same adapters `useChat` uses.

A full page reload is different: the hooks never start or resume a run on
mount, and the snapshot alone cannot re-attach to the stream β€” it records that
a run was in flight, not a stream position. Treat a `running` snapshot after a
reload as informational ("a run was still going when the page closed"). See
[Resumable Streams](../resumable-streams/overview) for the durability contract,
production adapters, and the one-time-side-effects note.

## What the browser snapshot holds

The browser snapshot never holds the generated bytes, only references to them.
Without an `artifacts` + `blobs` backend those references point at the provider's
own URL, which usually expires, so a snapshot opened much later can point at
media that is gone. Add the two stores (see
[Store the generated bytes](#store-the-generated-bytes)) to keep the files and
serve them from your own origin instead.
Loading
Loading