TanStack AI 的 persistence 只存一个线程的正文;「有哪些线程」是另一件事。
门面把它做成一个独立的索引:createThreadIndex() 在 localStorage 里存一份
ThreadMeta[],threadPersistence() 把它接到任意 ChatClientPersistence 上,
每次写入正文同时更新计数、预览、时间与自动标题。ThreadList 家族负责把索引画成列表——
选中、新建、行内重命名、归档、删除;搜索与分组是你在外面对数组做的事。
页顶 demo 不需要密钥:右侧会话走 脚本化传输,左侧列表与正文都真的写进了 浏览器(刷新还在;Reset 清掉这个 demo 自己的 key 与数据库)。
使用
import {
createThreadIndex,
fetchServerSentEvents,
groupThreadsByDay,
indexedDBPersistence,
threadPersistence,
ThreadList,
ThreadListArchive,
ThreadListDelete,
ThreadListGroup,
ThreadListGroupLabel,
ThreadListItem,
ThreadListNew,
ThreadListRename,
useChat,
useStoredState,
useThreadIndex,
} from '@gedatou/cadenza-ai'const index = createThreadIndex({ key: 'app:threads' })
const persistence = threadPersistence(index, indexedDBPersistence({ databaseName: 'app-chat' }))
const DAY_LABELS = { today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier' }
function Threads(): ReactElement {
const threads = useThreadIndex(index)
const [threadId, setThreadId] = useStoredState('app:threads:current', '')
return (
<div className="flex">
<ThreadList index={index} threads={threads} value={threadId} onValueChange={id => setThreadId(id)} className="block-96 inline-64">
<ThreadListNew>New thread</ThreadListNew>
{groupThreadsByDay(threads).map(group => (
<ThreadListGroup key={group.label}>
<ThreadListGroupLabel>{DAY_LABELS[group.label]}</ThreadListGroupLabel>
{group.threads.map(thread => (
<ThreadListItem key={thread.id} thread={thread}>
<ThreadListRename aria-label="Rename" />
<ThreadListArchive aria-label="Archive" />
<ThreadListDelete aria-label="Delete" />
</ThreadListItem>
))}
</ThreadListGroup>
))}
</ThreadList>
{threadId !== '' && <Chat key={threadId} threadId={threadId} />}
</div>
)
}
function Chat({ threadId }: { threadId: string }): ReactElement {
const chat = useChat({ connection: fetchServerSentEvents('/api/ai/chat'), persistence, threadId })
// TranscriptProvider / Transcript / Composer — see /docs/ai/conversation
}思路
一个线程有两半,各归各处:
- 正文归
persistence。useChat({ persistence, threadId })是 TanStack AI 的接口,indexedDBPersistence()是它自带的适配器:每个threadId一条{ messages, resume? }。ChatClientPersistence只有getItem/setItem/removeItem——没有枚举, 从它身上列不出「有哪些线程」。 - 索引归
createThreadIndex。一个localStorage键(默认cadenza-ai:threads)存整份ThreadMeta[];subscribe同时挂本地 emitter 与storage事件,多个 tab 免费同步。storage: 'memory'给 SSR 与测试。 - 两半由
threadPersistence(index, base)接起来。它包住适配器:setItem写完正文就index.touch(id, { messageCount, preview, title })——标题缺省取首条 user 消息的前 40 字({ title: false }关掉,交给生成式标题), 手动改过的标题不再被覆盖;removeItem同时index.remove(id)。 - 当前线程归你。
useChat的身份就是threadId,换线程 =key={threadId}重挂; 当前 id 用useStoredState存一份即可刷新不丢。
索引不做 IndexedDB 版本:localStorage 的 5 MB 天花板对几千条 ThreadMeta 绰绰有余。
会话列表
ThreadList 是一个滚动列表,children 里怎么排、每行放什么动作,由你组合;
省略 children 时每个线程渲染成一个没有动作的 ThreadListItem。
ThreadList ScrollArea > div[role=list];当前线程是受控的 value
├─ ThreadListNew index.create() 一个空线程并选中它
└─ ThreadListGroup role="group",一组
├─ ThreadListGroupLabel 组标题
└─ ThreadListItem 一行:Item + 标题 / 预览;data-active / data-archived / data-renaming
├─ ThreadListRename 切到行内改名输入框
├─ ThreadListArchive 切换 archived
└─ ThreadListDelete index.remove(id);确认由你包 AlertDialog- 选中:点行内容调
onValueChange(id, details),details.reason是'item-press';details.cancel()可以拦下这次切换。 - 新建:
ThreadListNew先index.create()再选中,reason同样是'item-press'; 空线程在第一条消息写入时被threadPersistence补上标题与预览。 - 重命名:
ThreadListRename把这一行换成一个自动聚焦的Input;Enter或失焦提交,Escape取消;空标题或没改动不写入。改过的标题优先于自动标题。 - 归档:
ThreadListArchive切换thread.archived,归档的线程在index.list()里排到最后; 要隐藏它们,在传threads之前过滤。 - 删除:
ThreadListDelete直接index.remove(id),不弹确认——把它包进 AlertDialog 的触发器里。它只删索引,正文还在适配器里: 要一并清掉,在onClick里调persistence.removeItem(thread.id)——经threadPersistence包过的适配器会顺手把索引项也删掉。 - 搜索:
threads是「已经过滤好的数组」——useThreadIndex(index)拿全量, 按标题 / 预览filter后再传进来。 - 分组:
groupThreadsByDay(threads)按updatedAt的本地日历日分成today/yesterday/earlier三桶,空桶省略;标签文案由你写。
页顶 demo 的完整源码(含搜索框、AlertDialog 确认与窄屏抽屉):
历史前插
翻页加载更早的消息时,读者正在看的那一行不该动。chat.setMessages([...older, ...chat.messages])
把旧行前插到记录顶部,Transcript 透传给视口的 preserveScrollOnPrepend(默认开启)会保住可见行——
前插内容有多高,scrollTop 就补多少。
const chat = useChat({ connection, persistence, threadId, initialMessages: latestPage })
// The next page of older rows arrived: prepend, never append
chat.setMessages([...olderPage, ...chat.messages])两个约束来自 MessageScroller:
- 消息 id 要稳定。 滚动器按行的身份保位,不是按视口边缘碰巧落在哪个像素;
TranscriptMessage把message.id交给MessageScrollerItem,分页数据里的 id 不能每次重新生成。 - 「加载更早」按钮不能是消息列表的第一行。 滚动器靠「原来排第一的那行有没有往下挪」认出前插,
一个永远排第一的控件会把每次前插都挡住,视口把新到的历史当成追加、从读者眼前滚走。
demo 把按钮放在记录框内、
Transcript之上——滚动容器之外;要让它随记录一起滚动, 直接组合MessageScrollerViewport,把按钮放在视口内、MessageScrollerContent之外。
setMessages 走与运行同一条持久化通路,所以经 threadPersistence 包过的适配器照常更新索引。
导入导出
messagesToMarkdown(messages, options?) 把一条线程渲染成纯 Markdown;JSON 直接序列化
chat.messages,导入走 chat.setMessages——它经过与运行同一条持久化通路,
所以 threadPersistence 会照常更新索引。
三个按钮各一行:
import type { UIMessage } from '@gedatou/cadenza-ai'
import { messagesToMarkdown } from '@gedatou/cadenza-ai'
// Markdown: `**User**` / `**Assistant**` blocks; thinking parts as block quotes when asked for
const markdown = messagesToMarkdown(chat.messages, { title: index.get(threadId)?.title, includeThinking: false })
await navigator.clipboard.writeText(markdown)
// JSON out / in
const json = JSON.stringify(chat.messages, null, 2)
chat.setMessages(JSON.parse(json) as UIMessage[])messagesToMarkdown 只写文本部件:工具调用、附件、结构化输出都跳过;
includeThinking: true 时推理部件作为引用块跟在所在消息里。
服务端持久化
正文也可以住在服务端:createChatHandler({ persistence }) 把
@tanstack/ai-persistence(可选 peer,请求时才动态 import)的 withPersistence
中间件挂上 POST,并让 GET ?threadId= 走 reconstructChat——客户端
useChat({ persistence: true, threadId }) 挂载时由 fetchServerSentEvents 内建的
hydrate 取回 { messages, activeRun, interrupts }。
// app/api/ai/chat/route.ts
import { openai } from '@gedatou/cadenza-ai/providers/openai'
import { createChatHandler } from '@gedatou/cadenza-ai/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
// memoryPersistence() is the in-process reference store — messages, runs, interrupts and
// metadata together; back it with your database in production.
const persistence = memoryPersistence()
export const { POST, GET } = createChatHandler({
providers: [openai],
persistence,
// Without `authorize`, anyone who guesses a threadId reads the whole transcript.
authorize: async (threadId, request) => userOwnsThread(await sessionUser(request), threadId),
})// client: no adapter — the server is the store
const chat = useChat({ connection: fetchServerSentEvents('/api/ai/chat'), persistence: true, threadId })服务端的 MessageStore 同样只有 loadThread / saveThread——整体覆盖、没有列表,
删除一条线程就是 saveThread(id, []);线程索引仍然是你的事:要么留在浏览器
(本页的 createThreadIndex),要么自己建一个列表接口。memoryPersistence() 带全了
messages / runs / interrupts / metadata 四个 store,所以等待批准的中断也能跨刷新恢复;
自己实现的适配器若声明 interrupts,就必须同时提供 runs。
断线续流
persistence 存的是落盘的正文,正在生成的那条回复还在流里。durability 把这条流也记下来:
createChatHandler({ durability }) 让 POST 走 toServerSentEventsResponse(stream, { durability: { adapter } })
——每个块追加进这次 run 的日志,每个 SSE 事件带上 id: 偏移;GET ?runId=(或原生的
Last-Event-ID 重连)走 resumeServerSentEventsResponse,从日志回放,不再跑一次模型。
import { createChatHandler, memoryStream } from '@gedatou/cadenza-ai/server'
export const { POST, GET } = createChatHandler({
providers: [openai],
persistence,
authorize,
// memoryStream keeps the logs in a process-global map: development, tests and single-process deployments.
durability: request => memoryStream(request),
})客户端不用改:fetchServerSentEvents 本身就是可续流的适配器——刷新后 hydrate 带回
activeRun,客户端用 joinRun(runId)(GET ?offset=-1&runId=)从头回放并接着收;
中途断线则带 Last-Event-ID 重连,reconnect 选项给重试节流与上限。两个选项一起开才有意义:
activeRun 来自 persistence 的 run store,回放来自 durability 的日志。
memoryStream 的日志住在进程内,完成的 run 过一段宽限期就被回收,未知或已回收的 run
续流会直接失败而不是挂起;多实例部署换 durableStream(request)(需要外部 Durable Streams 服务)。
docs 站不接服务端持久化,也不开 durability,页顶 demo 全部在本地。
状态与 className
每个部件的最外层都带 data-slot;行的状态是布尔型属性,在场时值为空串,
用 data-active: 这样的变体即可命中。
className 在这些部件上都是 string。ThreadList 的 className 落在 ScrollArea 根上——
高度从这里给;ThreadListItem 的落在 Item 上(默认带 data-active:bg-muted),
四个按钮的落在 Button 上。
导出的类型
// 索引
import type { ThreadDayGroup, ThreadDayLabel, ThreadIndex, ThreadIndexOptions, ThreadMeta } from '@gedatou/cadenza-ai'
import { createThreadIndex, groupThreadsByDay, newThreadId, threadPersistence, threadTitleFrom, useStoredState, useThreadIndex } from '@gedatou/cadenza-ai'
// 导出
import type { MessagesToMarkdownOptions } from '@gedatou/cadenza-ai'
import { messagesToMarkdown } from '@gedatou/cadenza-ai'
// 视图
import type { ThreadListArchiveProps, ThreadListChangeEventDetails, ThreadListChangeEventReason, ThreadListDeleteProps, ThreadListGroupLabelProps, ThreadListGroupProps, ThreadListItemProps, ThreadListItemState, ThreadListNewProps, ThreadListProps, ThreadListRenameProps } from '@gedatou/cadenza-ai'
import { useThreadListItem } from '@gedatou/cadenza-ai'ThreadMeta
运行时 API
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
ThreadList
滚动列表:ScrollArea 根 > div[role=list]。缺了它,其余部件抛
cadenza-ai: ThreadListContext is missing…。
ThreadListChangeEventDetails 是 ChangeEventDetails<'item-press' | 'none'>。
ThreadListGroup
div[role=group]。
ThreadListGroupLabel
组标题 div,收 ComponentProps<'div'>。
ThreadListItem
一行。内容区是一个选中按钮,或——改名中——一个行内 Input。
ThreadListItemState:{ active, archived, renaming }。自定义动作用
useThreadListItem() 拿到 { thread, active }。
ThreadListRename
Button variant="ghost" size="icon-xs",默认内容是铅笔图标;收
Button 的全部 props。onClick 先于内置动作执行,
event.preventDefault() 跳过内置动作。
ThreadListArchive
同上,默认内容是归档图标;点击 index.archive(id, !thread.archived)。
ThreadListDelete
同上,默认内容是垃圾桶图标;点击 index.remove(id),不弹确认。
ThreadListNew
Button variant="outline",没有默认内容——文案由你写;收
Button 的全部 props。点击 index.create() 并选中新线程。