Skip to content
Draft
15 changes: 15 additions & 0 deletions .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@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 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 @@ -256,6 +256,11 @@
"addedAt": "2026-07-22",
"updatedAt": "2026-07-26"
},
{
"label": "Generation Persistence",
"to": "persistence/generation-persistence",
"addedAt": "2026-07-23"
},
{
"label": "Controls",
"to": "persistence/controls",
Expand Down
148 changes: 148 additions & 0 deletions docs/persistence/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
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 two 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.
- **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: video, batch images, long audio, transcription of a big file. For a
quick one-shot image you show and forget, you can skip it.

It never stores the generated bytes. Only the run's identity, status, errors,
and result metadata (such as the media URL) are saved. See
[What it does not store](#what-it-does-not-store).

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

## 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 it does not store

The snapshot points at the media URL the provider returned, not the bytes.
Provider URLs usually expire, so a snapshot opened much later can point at media
that is gone. If you need the media to last, save the bytes to your own storage.
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
43 changes: 25 additions & 18 deletions packages/ai-angular/src/inject-generate-audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@ import type {
} from '@tanstack/ai-client'
import type { Signal } from '@angular/core'
import type { ReactiveOption } from './internal/to-reactive'
import type {
InjectGenerationOptions,
InjectGenerationResult,
} from './inject-generation'

/**
* Options for the injectGenerateAudio injectable.
*
* @template TOutput - The output type after optional transform (defaults to AudioGenerationResult)
*/
export interface InjectGenerateAudioOptions<TOutput = AudioGenerationResult> {
export interface InjectGenerateAudioOptions<
TOutput = AudioGenerationResult,
> extends Pick<
InjectGenerationOptions<AudioGenerateInput, AudioGenerationResult, TOutput>,
'persistence' | 'initialResumeSnapshot'
> {
/** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */
connection?: ConnectConnectionAdapter
/** Direct async function for audio generation */
Expand Down Expand Up @@ -48,7 +57,9 @@ export interface InjectGenerateAudioOptions<TOutput = AudioGenerationResult> {
*
* @template TOutput - The output type (after optional transform)
*/
export interface InjectGenerateAudioResult<TOutput = AudioGenerationResult> {
export interface InjectGenerateAudioResult<
TOutput = AudioGenerationResult,
> extends Omit<InjectGenerationResult<TOutput>, 'generate'> {
/** Trigger audio generation */
generate: (input: AudioGenerateInput) => Promise<void>
/** The generation result containing audio, or null */
Expand All @@ -59,10 +70,6 @@ export interface InjectGenerateAudioResult<TOutput = AudioGenerationResult> {
error: Signal<Error | undefined>
/** Current state of the generation */
status: Signal<GenerationClientState>
/** Abort the current generation */
stop: () => void
/** Clear result, error, and return to idle */
reset: () => void
}

/**
Expand Down Expand Up @@ -109,19 +116,19 @@ export function injectGenerateAudio<TTransformed = void>(
hookName: 'injectGenerateAudio',
outputKind: 'audio' as const,
}
const { generate, result, isLoading, error, status, stop, reset } =
injectGeneration<AudioGenerateInput, AudioGenerationResult, TTransformed>({
...options,
devtools,
})
const generation = injectGeneration<
AudioGenerateInput,
AudioGenerationResult,
TTransformed
>({
...options,
devtools,
})

return {
generate: generate as (input: AudioGenerateInput) => Promise<void>,
result,
isLoading,
error,
status,
stop,
reset,
...generation,
generate: generation.generate as (
input: AudioGenerateInput,
) => Promise<void>,
}
}
Loading
Loading