Agent SkillsTanStack/ai › ai-core/client-persistence

ai-core/client-persistence

GitHub

提供SPA聊天与生成的持久化方案。支持localStorage/sessionStorage/indexedDB三种适配器,区分客户端主导(缓存全量)与服务端主导(仅同步)模式。新增媒体生成任务的轻量级快照持久化,实现页面重载、中断恢复及多设备数据一致性。

packages/ai/skills/ai-core/client-persistence/SKILL.md TanStack/ai

Trigger Scenarios

需要实现聊天界面刷新后消息不丢失 用户切换设备或浏览器重启需恢复对话状态 需要保存图像/视频生成任务的中间状态以便恢复 要求敏感数据不存储在本地存储中

Install

npx skills add TanStack/ai --skill ai-core/client-persistence -g -y
More Options

Non-standard path

npx skills add https://github.com/TanStack/ai/tree/main/packages/ai/skills/ai-core/client-persistence -g -y

Use without installing

npx skills use TanStack/ai@ai-core/client-persistence

指定 Agent (Claude Code)

npx skills add TanStack/ai --skill ai-core/client-persistence -a claude-code -g -y

安装 repo 全部 skill

npx skills add TanStack/ai --all -g -y

预览 repo 内 skill

npx skills add TanStack/ai --list

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-experience for useChat itself.

No extra package. The adapters below ship in the framework packages (@tanstack/ai-react and 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-persistence and its ai-persistence/server skill.

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

  1. Finished run — transcript from the adapter (mode A) or server (mode B).
  2. Paused on interrupt — approval UI restored (from the adapter in mode A, the server hydrate in mode B).
  3. Still streaming — needs delivery durability on the route (toServerSentEventsResponse(stream, { durability: … })) so the client can joinRun and 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 threadId from the server via the connection's hydrateGeneration handler (the SSE/HTTP adapters issue a GET with ?threadId= to the same endpoint URL) and repaints it into the normal fields.
  • The server GET returns reconstructGeneration(persistence, request) from @tanstack/ai-persistence — it resolves the run by ?runId= (preferred) or the latest run linked to ?threadId=, and needs stores.generationRuns. Pair it with withGenerationPersistence on the generation route. See ai-core/media-generation and ai-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 / result for a finished run; use runId to 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-experienceuseChat, resumable connections
  • Resumable streams docs — mid-stream rejoin

Version History

  • 1cb04d5 Current 2026-07-31 17:39

    新增媒体生成任务(如图片生成)的持久化支持,允许保存轻量级运行快照;重构生成持久化API以复用聊天存储适配器,简化调用方式并统一接口体验。

  • 05280a5 2026-07-30 23:53

Same Skill Collection

.claude/skills/gap-analysis/SKILL.md
packages/ai-code-mode/skills/ai-code-mode/SKILL.md
packages/ai-mcp/skills/ai-mcp/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md
packages/ai-memory/skills/tanstack-ai-memory/SKILL.md
packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md
packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md
packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md
packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md
packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md
packages/ai-persistence/skills/ai-persistence/server/SKILL.md
packages/ai-persistence/skills/ai-persistence/SKILL.md
packages/ai-persistence/skills/ai-persistence/stores/SKILL.md
packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md
packages/ai/skills/ai-core/chat-experience/SKILL.md
packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
packages/ai/skills/ai-core/debug-logging/SKILL.md
packages/ai/skills/ai-core/locks/SKILL.md
packages/ai/skills/ai-core/middleware/SKILL.md
packages/ai/skills/ai-core/SKILL.md
packages/ai/skills/ai-core/tool-calling/SKILL.md
.claude/skills/triage-github/SKILL.md
packages/ai-sandbox/skills/ai-sandbox/SKILL.md
packages/ai/skills/ai-core/adapter-configuration/SKILL.md
packages/ai/skills/ai-core/media-generation/SKILL.md
packages/ai/skills/ai-core/structured-outputs/SKILL.md

Metadata

Files
0
Version
1cb04d5
Hash
076a3981
Indexed
2026-07-30 23:53

- 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-01 19:46
浙ICP备14020137号-1 $방문자$