Skip to content
Draft
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
9 changes: 8 additions & 1 deletion .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
---
'@tanstack/ai': minor
'@tanstack/ai-utils': minor
'@tanstack/ai-persistence': minor
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-solid': minor
Expand All @@ -7,7 +10,11 @@
'@tanstack/ai-angular': minor
---

Add client-side generation persistence: a lightweight resume snapshot for media generation activities.
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).

Expand Down
126 changes: 114 additions & 12 deletions docs/persistence/generation-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@ 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:
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: video, batch images, long audio, transcription of a big file. For a
quick one-shot image you show and forget, you can skip it.
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.

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

Expand Down Expand Up @@ -86,6 +89,104 @@ 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
Expand Down Expand Up @@ -174,10 +275,11 @@ 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
## What the browser snapshot holds

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.
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.
3 changes: 3 additions & 0 deletions packages/ai-persistence/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
"test:types": "tsc",
"test:oxlint": "oxlint src --type-aware"
},
"dependencies": {
"@tanstack/ai-utils": "workspace:*"
},
"peerDependencies": {
"@tanstack/ai": "workspace:*",
"vitest": "^4.1.10"
Expand Down
28 changes: 27 additions & 1 deletion packages/ai-persistence/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export type {
ChatTranscriptPersistence,
ChatPersistence,
ChatWithInterruptsPersistence,
// Generation media bytes (opt-in; pair `artifacts` with `blobs`)
ArtifactRecord,
ArtifactStore,
BlobBody,
BlobRecord,
BlobObject,
BlobListPage,
BlobPutOptions,
BlobListOptions,
BlobStore,
AIPersistence,
AIPersistenceOverrides,
ComposedAIPersistenceStores,
Expand All @@ -33,14 +43,30 @@ export type {
// AIPersistenceStores is intentionally NOT re-exported β€” use a named chat
// shape or AIPersistence<{ messages: MessageStore, … }>.

// Core artifact wire types (re-exported for convenience)
export type {
PersistedArtifactActivity,
PersistedArtifactRef,
PersistedArtifactRole,
} from '@tanstack/ai'

// Middleware (state only β€” locks live in @tanstack/ai as withLocks)
export { withPersistence, withGenerationPersistence } from './middleware'
export type {
WithPersistenceOptions,
GenerationArtifactDescriptor,
GenerationArtifactExtractionInput,
GenerationArtifactNameInput,
} from './middleware'

// Server helper: rehydrate a thread's messages for a client load
export { reconstructChat } from './reconstruct'
export type { ReconstructChatOptions } from './reconstruct'

// Reference in-memory implementation (state stores only)
// Server helpers: retrieve a persisted generation artifact + its bytes
export { retrieveArtifact, retrieveBlob, artifactBlobKey } from './retrieve'

// Reference in-memory implementation (state stores + generation media)
export { memoryPersistence } from './memory'

// Interrupt controller
Expand Down
Loading
Loading