时间线
你不需要管理对话。你只需滚动你的人生。Previously 用一条垂直时间线取代了聊天线程列表——你的故事如同一部自传,最旧的在顶部,最新的在底部。
时间线是 Previously 的主要交互界面——一条垂直滚动你的对话历史记录,从上到下阅读,实时聊天在最底部,向上滚动时过去便展现在你身后。
不是聊天列表,也不是搜索栏
时间线是大多数 AI 聊天产品采用的两种主流 UI 模式的明确替代方案。它不是需要你手动管理、重命名、删除和搜索的对话线程列表。它不是一个输入关键词、期望正确记忆浮现的搜索栏。这两种模式都给人类带来负担——它们假设你会自己整理记忆。
Previously 摒弃了这一假设。取而代之的是,它呈现一条单一的垂直时间线——实时对话发生在最底部边缘。
三个区域,一个滚动器
页面是一个单一垂直堆叠的滚动表面,按顺序包含三个区域:服务端渲染的英雄区、记忆时间线(TimelinePanel)和实时聊天消息——全部位于一个 MessageScroller 内。时间线位于消息之上,而非消息列表内部。它们是同一个滚动容器中的同级区域。向上滚动回顾过去。向下滚动继续你上次离开的位置。
时间顺序排列
数据从 hook 获取时是最新优先,显示时反转顺序。日期组和组内的 slice 均按最旧优先的顺序渲染。
按日期分组的 UI
可见的时间线按精确日历日期分组 slice。辅助函数 formatSliceDate 返回:
| 条件 | 标签 |
|---|---|
| 同一天 | "今天" |
| 前一天 | "昨天" |
| 同一年,更早 | 月 + 日(例如 "6 月 14 日") |
| 跨年 | 年 + 月 + 日(例如 "2025 年 3 月 10 日") |
每个日期组渲染一个动画头部(使用 NumberTicker 组件显示年、月、日),以及每个 slice 行的 HH:MM 时间标记。支持本地化:中文渲染为 "2025 Year 11 Month 21 Day" 并带有后缀标签;英文渲染为 "November 21 [2025]",仅在跨年时显示年份。
"现在"标记
在时间线的最底部,一个大型动画 "Now" 文字标志着记录的过去交接给当前对话的位置。使用 TextGenerateEffect 渲染,尺寸为 text-5xl sm:text-6xl(响应式),细字重(font-light),位于居中块内,上下内边距较大(pt-24 pb-20)。这是时间线的终点:时间线向下延伸至此,然后交接给下方的实时聊天。
时间间隔标题卡片(冷开场)
当你离开一段时间后返回 Previously,且聊天仍为空时,一个电影感的时间间隔标题卡片会出现在 "Now" 标记上方——一个从最后记录时刻跨越到现在的标签。
间隔根据最后一个 slice 的 start 与 Date.now() 之间的经过时间进行分类:
| 经过时间 | 渲染为 |
|---|---|
| < 5 分钟 | "片刻之后" |
| 1–59 分钟 | " 分钟后" |
| 1–23 小时 | " 小时后" |
| 1–6 天 | " 天后" |
| 1–4 周 | " 周后" |
| ≥ 5 周 | " 个月后" |
关键细节:没有"年"这个单位。 大约两年的间隔渲染为 "24 个月后",而不是 "2 年后"。间隔卡片仅在挂载后计算,以避免 SSR/水合时因墙上时钟产生的不匹配。仅在聊天还没有消息时显示——一旦你开始说话,间隔卡片就会消失。
分页:"更早的记忆"
向上滚动可通过游标分页浏览历史记录。"更早的记忆"按钮位于时间线面板的顶部。点击它会加载比当前已加载的最旧 slice 更早的 10 个 slice。较新的 slice 出现在现有 slice 下方;更旧的 slice 加载到上方,保持最旧在顶部的原则。分页游标为已加载的最旧 slice 的 start 时间戳。
Slice 行:延迟加载与可展开
时间线中的每个 slice 渲染为 TimeSliceRow:
- 延迟内容加载——完整的 slice 主体在挂载时通过
getSliceContent(slice.slice_id)获取,加载期间显示旋转器 - 默认视图——显示第一个用户+助手的对话回合(通常是一条用户消息和助手的回复)
- 向下展开——"查看更多"按钮在第一个对话回合下方显示后续轮次,保持时间顺序的阅读方向
- 摘要说明——slice 的 YAML
summary字段在对话回合内容下方渲染为斜体说明文字 - 未完结事项与已决事项——以计数形式显示("3 进行中 · 2 已决定"),点击后可展开为带标签的胶囊
- 字符计数——超过 300 字符的单个对话回合会被截断,并显示"展开全部( 字符)"按钮
两个不同的按时间分组系统
Previously 中存在两个独立的按时间分组系统,服务于不同的受众。
可见 UI(为你设计)
屏幕上的时间线按精确日历日期分组——今天、昨天或本地化格式的日期——带有动画的年/月/日头部和 HH:MM slice 标记。
LLM 情景上下文(为代理设计)
当路由为 Pro 组装系统提示时,它会通过 buildTimelineEpisodicContext 构建一个 ## Episodic Memory Timeline 部分。这是一个注入到模型上下文中的 Markdown 块,而非渲染在屏幕上。
包含当前会话信息(slice_id、轮次数、焦点、摘要、未完结事项、已决事项),随后是召回结果分组:
| 分组标签 | 天数范围 |
|---|---|
| "今天 / 本周" | ≤ 7 |
| "本月" | ≤ 30 |
| "几个月前" | ≤ 180 |
| "去年" | ≤ 365 |
| "更早" | > 365 |
每条命中行显示 slice_id、相对时间标签、Flash 理由和相关性分数。当 Flash 未找到任何内容时,该部分会明确说明,并引导 Pro 前往 memory/episodic/strands.json 进行更深层的探索。
数据流
时间线通过服务端操作(server actions)填充:
- 初始加载——
getEpisodicState()返回最近的 slice 作为active、一个recentslice 数组(包含摘要和元数据),以及一个hasMore布尔值 - 分页——
getMoreSlices(before: ISO 时间戳, limit: 10)获取更旧的 slice;结果追加到slices数组的末尾 - DEMO_MODE——当
DEMO_MODE=true时,扫描深度扩展到 48 个月,而非正常的 1-2 个月
原子单位:Slice
时间线上的每一行代表一个 slice——一个单独的对话片段,以 Markdown 文件形式存储在:
memory/episodic/slices/2025/11/21/0825.md
路径编码了完整的时间戳:年/月/日/时-分。一个日历日是一个目录,可能包含多个 slice。一个 slice 在你开始对话时打开,并在静默 30 分钟后自动关闭。没有容量限制,也没有话题切换规则——切片纯粹由时间驱动。
每个 slice 携带带有结构化元数据的 YAML frontmatter(focus、summary、open_loops、decisions、tags、emotional_tone、status、start/end 时间戳),使其无需专有工具即可被机器读取。