Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
16 changes: 16 additions & 0 deletions .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@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 client-side generation persistence: a lightweight 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. 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
178 changes: 178 additions & 0 deletions docs/persistence/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
---
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: 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.
- **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 (like the result id, model, or video job id) 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 './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.

## 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'

const snapshots = localStoragePersistence({ 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 it does not store

The snapshot stores run identity and result metadata β€” ids, model, status, a
video `jobId`, an expiry timestamp β€” never the generated bytes, and it does not
carry the provider's media URL. If you need the media itself to survive a
reload, save it to your own storage from `onResult`; durable artifact storage
is coming as a follow-up.
77 changes: 72 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,22 @@ 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 generation resume
// snapshot. Only run identity, status, errors, and result metadata are stored
// β€” never the generated image bytes. The client namespaces its record under
// `generation:<id>`, and reads it back on mount so the last run's outcome
// survives a full page reload.
const imageSnapshots = localStoragePersistence({
keyPrefix: 'example:',
})

function StreamingImageGeneration() {
const [prompt, setPrompt] = useState('')
const [numberOfImages, setNumberOfImages] = useState(1)
Expand Down Expand Up @@ -69,6 +81,49 @@ 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,
})

const snapshot = hookReturn.resumeSnapshot
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">
{hookReturn.resumeState ? (
<p className="text-gray-400">
Run in flight:{' '}
<span className="text-white">{hookReturn.resumeState.runId}</span>
</p>
) : snapshot ? (
<p className="text-gray-400">
Last run:{' '}
<span className="text-white">
{snapshot.status}
{snapshot.error ? ` β€” ${snapshot.error.message}` : ''}
{snapshot.result?.model ? ` (${snapshot.result.model})` : ''}
</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 +225,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 +270,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 +290,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
6 changes: 6 additions & 0 deletions packages/ai-angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,10 @@ export {
type VideoGenerateInput,
type VideoGenerateResult,
type VideoStatusInfo,
type GenerationPersistence,
type GenerationResumeSnapshot,
type GenerationResumeState,
type GenerationResumeStatus,
type GenerationPendingArtifact,
} from '@tanstack/ai-client'
export type { PersistedArtifactRef } from '@tanstack/ai/client'
Loading
Loading