Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@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: 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 observed `pendingArtifacts` / `resultArtifacts`). 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. The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too. On reload the snapshot is surfaced for observability; it exposes no `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called.

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 @@ -253,6 +253,11 @@
"addedAt": "2026-07-22",
"updatedAt": "2026-07-23"
},
{
"label": "Generation Persistence",
"to": "persistence/generation-persistence",
"addedAt": "2026-07-23"
},
{
"label": "Controls",
"to": "persistence/controls",
Expand Down
249 changes: 249 additions & 0 deletions docs/persistence/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
---
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: its id, whether it finished,
and any error. This is a small read-only snapshot kept in the browser.
- **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 '@tanstack/ai-persistence-drizzle/sqlite'

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

export async function POST(request: Request) {
const durability = memoryStream(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)],
})

// withGenerationPersistence records the run's status and result.
// 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.
`withGenerationPersistence` records runs whenever a `runs` store is present. 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`. The client writes a snapshot as the run
streams and reads it back on load:

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

const snapshots = localStoragePersistence({ keyPrefix: 'my-app:generation:' })

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>Last run: {image.resumeState.runId}</p> : null}
</section>
)
}
```

`image.resumeState` holds the last run's id once a run has streamed. The
snapshot is read-only, so it never re-runs the provider. A generation starts
only when you call `generate(...)`.

## 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 do not start a run on mount, so they
will not reconnect on their own. What survives the reload is the snapshot, which
holds the `runId`, so you can trigger a reconnect from it yourself. 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.
68 changes: 63 additions & 5 deletions examples/ts-react-chat/src/routes/generations.image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useGenerateImage } from '@tanstack/ai-react'
import type { UseGenerateImageReturn } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import {
fetchServerSentEvents,
localStoragePersistence,
} from '@tanstack/ai-client'
import { resolveMediaPrompt } from '@tanstack/ai'
import { generateImageFn, generateImageStreamFn } from '../lib/server-fns'

// Reuse the shared web-storage adapter for the lightweight, read-only
// generation resume snapshot. Only run identity, status, errors, and result
// metadata are stored — never the generated image bytes.
const imageSnapshots = localStoragePersistence({
keyPrefix: 'example:generation:',
})

function StreamingImageGeneration() {
const [prompt, setPrompt] = useState('')
const [numberOfImages, setNumberOfImages] = useState(1)
Expand Down Expand Up @@ -69,6 +79,42 @@ function ServerFnImageGeneration() {
)
}

function PersistedImageGeneration() {
const [prompt, setPrompt] = useState('')
const [numberOfImages, setNumberOfImages] = useState(1)

const hookReturn = useGenerateImage({
id: 'persisted-image',
connection: fetchServerSentEvents('/api/generate/image'),
persistence: imageSnapshots,
})

return (
<div className="space-y-4">
<div className="rounded-lg border border-orange-500/20 bg-gray-800/50 px-4 py-3 text-sm">
<p className="text-gray-400">
Resume status: <span className="text-white">{hookReturn.status}</span>
</p>
{hookReturn.resumeState ? (
<p className="text-gray-400">
Last run:{' '}
<span className="text-white">{hookReturn.resumeState.runId}</span>
</p>
) : (
<p className="text-gray-500">No persisted run yet.</p>
)}
</div>
<ImageGenerationUI
{...hookReturn}
prompt={prompt}
setPrompt={setPrompt}
numberOfImages={numberOfImages}
setNumberOfImages={setNumberOfImages}
/>
</div>
)
}

function ImageGenerationUI({
prompt,
setPrompt,
Expand Down Expand Up @@ -170,9 +216,9 @@ function ImageGenerationUI({
}

function ImageGenerationPage() {
const [mode, setMode] = useState<'streaming' | 'direct' | 'server-fn'>(
'streaming',
)
const [mode, setMode] = useState<
'streaming' | 'direct' | 'server-fn' | 'persisted'
>('streaming')

return (
<div className="flex flex-col h-[calc(100vh-72px)] bg-gray-900 text-white">
Expand Down Expand Up @@ -215,6 +261,16 @@ function ImageGenerationPage() {
>
Server Fn
</button>
<button
onClick={() => setMode('persisted')}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
mode === 'persisted'
? 'bg-orange-600 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
Persisted
</button>
</div>
</div>
</div>
Expand All @@ -225,8 +281,10 @@ function ImageGenerationPage() {
<StreamingImageGeneration key="streaming" />
) : mode === 'direct' ? (
<DirectImageGeneration key="direct" />
) : (
) : mode === 'server-fn' ? (
<ServerFnImageGeneration key="server-fn" />
) : (
<PersistedImageGeneration key="persisted" />
)}
</div>
</div>
Expand Down
Loading
Loading