Conversation 对话

把只追加的事件日志渲染为对话。这个元素只拥有三件麻烦且容易做错的事,此外什么都不做:把事件投影成节点、让视图吸附在底部且不覆盖用户自己的滚动操作、以及把行与节点列表对账。

适用场景:当你要渲染一段流式记录(聊天、Agent 会话、日志),并且希望每一种内容(消息、工具调用、状态行)都作为独立的注册单元,而不是让渲染器里的判断分支越堆越多时。

消息或工具调用长什么样是注册进来的 view,不是这个元素的职责。它的投影层是 ranuts/conversation,滚动来自 ranuts/utilscreateBottomFollower

快速开始

<r-conversation empty="暂无消息" style="height: 400px"></r-conversation>
const chat = document.createElement('r-conversation');

chat.register({
  kind: 'message',
  // 哪些事件是我的,以及属于哪个节点。
  match: (e) =>
    e.type === 'message/start'
      ? { id: e.id, role: 'start' }
      : e.type === 'message/delta'
        ? { id: e.id, role: 'update' }
        : null,
  // 折叠进我自己的状态。
  start: () => ({ text: '' }),
  update: (state, e) => ({ text: state.text + e.text }),
  // 逐 token 的增量合并为每帧一次重绘;离散事实不等待。
  publication: (e) => (e.type === 'message/delta' ? 'animation-frame' : 'immediate'),
  // 状态如何抵达屏幕。
  mount: () => document.createElement('r-markdown'),
  patch: (el, node) => {
    el.content = node.state.text;
  },
});

chat.push({ type: 'message/start', id: 'm1' });
chat.push({ type: 'message/delta', id: 'm1', text: '你好' });

container.append(chat);

正文行应当使用 <r-markdown>:它默认的 mode="streaming" 已经会补全半截流出的 **bold、反引号、链接与 $$ 数学,view 里不需要再解决一遍。

会咬人的几条规则

  • 所有 view 必须在第一次 push 之前注册完。 投影只根据注册集合构建一次,之后再注册的 view 会静默错过所有已折叠的事件。元素会直接抛错,而不是这么做。
  • update 折叠状态,patch 把状态写进 DOM。 两者刻意分开命名,因为它们是不同的活:patch 不折叠任何东西,而且在流式行上每帧都要跑一次,必须便宜。
  • mount 可以省略。 没有 mount 的 view 只贡献状态,供其他 view 通过 reader.previous 读取,自身不渲染。
  • 行保持它打开时的位置。 流式消息不会每来一个增量就跳到列表末尾。

底部跟随

默认开启。内容到达时视图吸附在底部;用户往上滚的瞬间停止;用户滚回底部时重新吸附,且任何时候都不会覆盖用户的手动滚动,因为跟随器判断的是「自己写入的滚动」和「用户的滚动」之间的区别,而不是去监听输入设备。

chat.addEventListener('pinnedchange', (e) => {
  jumpButton.hidden = e.detail.pinned;
});

follow="false" 让用户从一开始就掌握控制权;scrollToBottom() 把控制权收回。加载更早的内容时,在前插之前调用 captureAnchor()、之后调用 restoreAnchor(),用户就会一直看着他们正在看的那一行。

API 参考

属性

属性 类型 默认值 说明
follow boolean true 在用户滚离底部之前持续跟随新内容。
empty string '' 投影尚未产出任何行时显示的文本,为空则不显示。
pinned boolean true 只读。视图当前是否正在跟随新内容。
sheet string '' 注入元素 Shadow DOM 的 CSS。

方法

方法 说明
register(view) 注册一种内容。第一次 push 之后调用会抛错。
push(event) 投影一个事件,并渲染它改变的部分。
reset() 丢弃全部节点与行,保留已注册的 view。
scrollToBottom() 滚动到底部并恢复跟随。
captureAnchor(key?) 在前插更早内容之前记住某一行的位置。
restoreAnchor() 把记住的那一行恢复到原来的位置。

事件

事件 detail 触发时机
pinnedchange { pinned: boolean } 获得或失去底部跟随时。

插槽

插槽 说明
footer 行下方的吸底区域,输入框放这里,组件会监测它的高度变化。

Part

conversation(滚动容器)、listrowfooterempty

每一行还带有 data-kinddata-key,消费方无需伸进 Shadow 树即可定位或设置样式。

相关

自定义样式

<r-conversation> 自身暴露了 14 个 CSS 自定义属性,另外还会读取主题里的语义令牌。令牌设在任何能继承到的地方都有效,比如 :root、外层容器,或元素本身:

r-conversation {
  --ran-conversation-background: var(--ran-color-bg-subtle);
}

Part:conversation · empty · footer · list · older

完整清单见样式令牌;该选哪个令牌见设计系统