ai-core/client-persistence
GitHub提供SPA聊天与生成的持久化方案。支持localStorage/sessionStorage/indexedDB三种适配器,区分客户端主导(缓存全量)与服务端主导(仅同步)模式。新增媒体生成任务的轻量级快照持久化,实现页面重载、中断恢复及多设备数据一致性。
Trigger Scenarios
Install
npx skills add TanStack/ai --skill ai-core/client-persistence -g -y
SKILL.md
Frontmatter
{
"name": "ai-core\/client-persistence",
"type": "sub-skill",
"library": "tanstack-ai",
"sources": [
"TanStack\/ai:docs\/persistence\/client-persistence.md",
"TanStack\/ai:docs\/persistence\/overview.md"
],
"description": "Browser chat persistence on useChat \/ ChatClient: localStoragePersistence, sessionStoragePersistence, indexedDBPersistence. Client-authoritative (adapter, full transcript) vs server-authoritative (persistence: true, no client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), same two modes as chat: client-driven (adapter) persists a lightweight resume snapshot under generation:<threadId> (threadId is REQUIRED with persistence); server-driven (persistence: true) hydrates the last generation from the server on mount, nothing cached. No extra package: the adapters ship in the framework packages.\n",
"library_version": "0.10.0"
}
Client Persistence
Builds on ai-core, and on
ai-core/chat-experienceforuseChatitself.No extra package. The adapters below ship in the framework packages (
@tanstack/ai-reactand friends, re-exported from@tanstack/ai-client), so browser persistence needs nothing installed beyond what a chat UI already has. The server half is a separate package — see@tanstack/ai-persistenceand itsai-persistence/serverskill.
A ChatClient / useChat keeps messages in memory. The persistence option
stores one record per threadId so a reload can repaint the transcript,
restore a pending interrupt, and rejoin an in-flight run.
Import adapters from the framework package (not @tanstack/ai-client
unless vanilla JS):
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
sessionStoragePersistence,
indexedDBPersistence,
} from '@tanstack/ai-react'
Adapters
| Adapter | Survives | Notes |
|---|---|---|
localStoragePersistence() |
Reloads + browser restarts | Sync hydrate; quota-bound; JSON codec default |
sessionStoragePersistence() |
Reloads in the same tab | Cleared when tab/session ends |
indexedDBPersistence() |
Reloads + restarts | Async open (first paint may be empty briefly); structured clone |
All default to the chat persisted-state shape — no type argument or codec required for normal use.
Mode A — cache everything (client-authoritative)
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat', // stable — required
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...
}
Bare adapter ≡ full transcript + resume pointer. Browser owns history; server
(if any) mirrors when you post non-empty messages.
Best for: SPA, offline-first, single device, moderate conversation size.
Mode B — server-authoritative (persistence: true)
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// ...
}
Nothing is cached client-side: no transcript, no resume pointer.
On mount, useChat hydrates the thread from the server by threadId
(paint + tail active run). Same path for another device. Pair with server
withPersistence + a hydrate route (reconstructChat or equivalent).
Best for: large transcripts, multi-device, compliance (no message bodies in browser storage).
What a reload restores
- Finished run — transcript from the adapter (mode A) or server (mode B).
- Paused on interrupt — approval UI restored (from the adapter in mode A, the server hydrate in mode B).
- Still streaming — needs delivery durability on the route
(
toServerSentEventsResponse(stream, { durability: … })) so the client canjoinRunand finish the reply. Persistence alone is not enough.
Stable threadId is the identity
Persistence keys on threadId. The hooks have no separate id option — a
chat's identity is its threadId. Without a stable one, each load is a new
chat. Generate it server-side or from a route param the user owns; do not
randomize per mount.
Generation hooks: server-driven only
The generation hooks (useGenerateImage, useGenerateVideo, useGeneration,
useSummarize, useTranscription, …) take a persistence option too, but it is
boolean only — there is no storage-adapter mode, and the browser caches
nothing. The hooks are transparent, mirroring useChat: a reload repaints the
hook's
normal fields — status ('idle' / 'generating' / 'success' /
'error'), error, and result — as if the run had just finished. There is
no resumeSnapshot, resumeState, pendingArtifacts, or resultArtifacts
field. The one extra field is runId: the id of the generation job currently
running, or null when nothing is in flight. The persisted record holds run
identity, status, error, and result metadata (ids, model, a provider video job
id), never the generated media bytes.
The hook return is exactly generate / result / isLoading / error /
status / stop / reset / runId.
Turning it on (persistence: true)
const image = useGenerateImage({
threadId, // REQUIRED — the scope the last generation is hydrated under
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})
// After a reload: image.status / image.result / image.error are the last
// generation for `threadId`, fetched from the server — nothing was cached.
The server half — the same route handles the run and the hydration GET:
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructGeneration,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
// Needs `stores.generationRuns`; `memoryPersistence()` ships one.
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
if (threadId === undefined) {
throw new Error('Generation persistence requires a `threadId`.')
}
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
// The stable slot this run fills. Required by persistence: the run record
// is filed under it, and the client hydrates by it on mount.
threadId,
stream: true,
middleware: [withGenerationPersistence(persistence)],
}),
)
}
// Mount-time hydration: resolves `?runId=` (preferred) or the latest run linked
// to `?threadId=`, and returns `{ resumeSnapshot, activeRun }`.
export function GET(request: Request) {
return reconstructGeneration(persistence, request, {
// Multi-user routes MUST authorize: the ids come from the caller. Derive
// identity from server-side session state, then check ownership.
authorize: async (id, req) => {
// const user = await auth(req)
// return user != null && (await db.threadOwnedBy(user.id, id))
void id
void req
return true
},
})
}
- Nothing is cached client-side. On mount the client hydrates the last
generation for its
threadIdfrom the server via the connection'shydrateGenerationhandler (the SSE/HTTP adapters issue aGETwith?threadId=to the same endpoint URL) and repaints it into the normal fields. - The server
GETreturnsreconstructGeneration(persistence, request)from@tanstack/ai-persistence— it resolves the run by?runId=(preferred) or the latest run linked to?threadId=, and needsstores.generationRuns. Pair it withwithGenerationPersistenceon the generation route. Seeai-core/media-generationandai-persistence. - Best for multi-device / compliance (no generation metadata in browser storage), exactly like chat's server-authoritative mode.
Restoring media: byte storage + artifactUrl
result comes back with its media only when the server persists the bytes
(stores.artifacts + stores.blobs) AND withGenerationPersistence is given an
artifactUrl mapper:
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
})
artifactUrl stamps a durable app-origin URL onto each persisted ref and
rewrites the live result's media to it, so live and restored results match. The
durable refs travel on result.artifacts; on restore the hook rebuilds result
from them, so result.images[i].url (or a video's result.url) serves from your
own origin. result.artifacts is the whole artifact surface on the hook.
Without byte storage, a reload restores status / error and result stays
null.
Also worth knowing:
stop()marks the record no longer resumable;reset()clears the in-memory snapshot.- Nothing auto-runs from a hydrated record —
generate(...)is always explicit. - Use
status/resultfor a finished run; userunIdto tell that a run was still generating when the page closed, and to name it to your own server (to cancel or poll the provider job —stop()only aborts the local stream).
Common mistakes
HIGH: No threadId
Record cannot be found after reload.
HIGH: Passing id to useChat
Removed — threadId is the identity. (ChatClient still accepts id directly
as a lower-level escape hatch for keying storage separately from the wire
thread; the framework hooks do not.)
HIGH: persistence: true without server history
Empty chat after reload unless the server can reconstruct by threadId.
MEDIUM: Huge transcripts in localStorage
Quota and main-thread cost. Prefer persistence: true + server store, or
IndexedDB with care.
MEDIUM: Expecting multi-device sync from client storage alone
localStorage is per-browser. Use server persistence for multi-device.
Cross-references
- ai-persistence/server (
@tanstack/ai-persistence) — authoritative server half - ai-core/chat-experience —
useChat, resumable connections - Resumable streams docs — mid-stream rejoin
Version History
-
1cb04d5
Current 2026-07-31 17:39
新增媒体生成任务(如图片生成)的持久化支持,允许保存轻量级运行快照;重构生成持久化API以复用聊天存储适配器,简化调用方式并统一接口体验。
- 05280a5 2026-07-30 23:53


