生态产品多个设计器现已发布候选版本

查看生态总览
跳转到内容

MessageFeed 消息流 alpha ​

一段会话的消息序列:粘底跟随、条目集合语义、键盘遍历与一个统一的播报区。

用法 ​

消息内容全部由作者编写;组件管理的是集合语义、粘底与播报区

我
这个组件负责什么?
助手
集合语义、粘底跟随,以及一个统一的播报区。
我
气泡样式呢?
助手
气泡、头像、时间都由你自己写,按条目上的 data-role 出样式。

组件结构 ​

加粗的是必需部件。

data-scope="message-feed":root · viewport · list · item · item-label · separator · pending-indicator · scroll-to-end-trigger · unread-count · live-region

示例 ​

粘底跟随与播报 ​

新消息增长时自动到底部,向上翻即解除;一轮结束后在播报区朗读一句

第 1 条:往上翻一下,粘附会解除,右下角出现回到底部。

按角色分侧 ​

条目上带 data-role,左右分侧与气泡在使用者一侧编写,组件不预设这层外观

我
帮我把上面那段改成三句话。
助手
好的,三句话的版本如下,保留了原来的结论顺序。
我
第二句再短一点。
助手
已经把第二句压到十二个字,其余不动。

运行态与播报 ​

status 由宿主持有,组件只把它透出为 root 上的 data-state;已发送、等首个片段时列表之后的呼吸点亮起,首个片段一到就收;播报只发生在 live-region 中,一轮结束后才写入一句

我
帮我写一段开场白。
status:idle

触底加载更多 ​

stick-change 报告到达底部,宿主据此获取下一页;先向上翻一段再滚回底部,取回的消息接在后面

第 1 条 · 先往上翻一段,再滚回底部
第 2 条 · 先往上翻一段,再滚回底部
第 3 条 · 先往上翻一段,再滚回底部
第 4 条 · 先往上翻一段,再滚回底部
第 5 条 · 先往上翻一段,再滚回底部
第 6 条 · 先往上翻一段,再滚回底部
第 7 条 · 先往上翻一段,再滚回底部
第 8 条 · 先往上翻一段,再滚回底部
已加载 8 条 · 第 1 / 3 页

向上加载更早的消息 ​

直接监听视口的滚动事件:滚到接近顶部时获取上一页,取回的消息插在最前面,正在阅读的位置不会被顶走

第 33 条 · 会话记录
第 34 条 · 会话记录
第 35 条 · 会话记录
第 36 条 · 会话记录
第 37 条 · 会话记录
第 38 条 · 会话记录
第 39 条 · 会话记录
第 40 条 · 会话记录
已加载 8 条 · 最早到第 33 条

跳到指定的一条 ​

消息 id 就是锚点:Vue 侧使用 root 插槽提供的 scrollToItem / focusItem,自定义元素侧按同一个 id 取节点自行滚动

第 1 条 · 会话记录
第 2 条 · 会话记录
第 3 条 · 会话记录
第 4 条 · 会话记录
第 5 条 · 会话记录
第 6 条 · 会话记录
第 7 条 · 会话记录
第 8 条 · 会话记录
第 9 条 · 会话记录
第 10 条 · 会话记录
第 11 条 · 会话记录
第 12 条 · 会话记录
第 13 条 · 会话记录
第 14 条 · 会话记录
第 15 条 · 会话记录
第 16 条 · 会话记录
(还没跳过)

按日期分隔 ​

跨天的消息之间放一条 separator,与条目平级写在列表里;它对读屏隐藏,时间由消息自己的时间戳表达

我 · 21:40
明天的发布清单整理好了吗?
助手 · 21:41
整理好了,一共 12 项,明早再核一遍。
我 · 09:02
开始核对吧。
助手 · 09:02
第 1 项:构建产物已上传。

回到底部带未读数 ​

离开底部期间新到的消息记成未读,数字挂在回到底部按钮上并进入它的可访问名;回到底部即清零

第 1 条:往上翻,之后到的消息会记成未读。
第 2 条消息。
第 3 条消息。
第 4 条消息。
第 5 条消息。
第 6 条消息。
第 7 条消息。
第 8 条消息。

消息动作条 ​

每条消息下挂一条工具条:复制交给剪贴板;重新生成、编辑重发、切换分支、失败重试与截断续写各是会话容器 createThreadStore 上的一个方法,界面只照快照渲染

我
怎么准备一次发布?
助手
发布前冻结改动、跑完回归,发布后盯住告警。

设计指引 ​

何时使用 ​

  • AI 对话或聊天界面的消息列表。
  • 内容从底部持续生长,需要始终跟随到底,但用户向上翻时不被拉回。

何时不用 ​

  • 内容不分条、只是持续追加的输出(运行日志、命令回显)时,使用日志。两者的粘底、回到底部与播报区是同一套,差别只在是否需要条目集合语义与逐条遍历。
  • 只是一列静态卡片时,使用列表。
  • 消息数以万计时,本组件不与虚拟滚动组合,键盘遍历要求条目都在活动 DOM 中;长会话请配合无限滚动分批加载并自行截断历史。

特性 ​

  • 粘底跟随:内容增高时自动到底,用户上滚即解除,滚回底部阈值内自动恢复。向上插入历史消息时补偿滚动位置,视口不跳动。
  • “回到底部”只判断是否在底部,不判断粘附意图:粘附中但内容尚未追上时按钮不显示。
  • “回到底部”是 Action Control floating 档的圆钮,比消息流的 size 低一档、最低 sm(sm / md 时 32px,lg 时 40px)。
  • 整份消息列表只占一个 Tab 停靠位:PageDown / PageUp 在消息之间移动,Ctrl+End / Ctrl+Home 一步移到消息流之外(会话界面中通常是输入框)。
  • 消息内容全部由作者编写:气泡、头像、时间、动作条都不是本组件的部件。
  • 新生成的消息与出现的“回到底部”各带一段淡入位移;减弱动效由令牌层收敛,不需要另行关闭。
  • 已发送、等首个片段(status 为 submitted)时,放在列表之后的 pending-indicator 显示为一颗呼吸的圆点,首个片段到来即收起;它只给视觉看,进度由宿主写进播报区。减弱动效下圆点静止。
  • “回到底部”留空时皮肤绘制向下的字形,放入节点即替换为自定义图形;只放入 unread-count 时字形照旧。
  • 未读数:离开底部期间 count 的增量累加成未读条数,回到底部即清零。把 unread-count 放进“回到底部”按钮里,它显示条数、没有未读时收起;条数同时写进按钮的可访问名,角标本身对读屏隐藏。
  • 按日期分隔:separator 与条目平级写在内容层里,显示“今天”“9 月 27 日”这类标注,对读屏隐藏;怎么分组由作者按消息时间决定,组件不解析日期。
  • 应用设为 data-material="liquid" 时,“回到底部”换成液态面:按下层换色调,按住时液面随手指形变。

组合 ​

  • 正文使用流式正文,代码使用代码视图。
  • 每条消息的动作条使用工具栏,复制使用剪贴板:剪贴板根的插槽给出 copy 与 copied,复制按钮仍写成工具栏的一项,方向键照常走到它。
  • 动作条上的重新生成、编辑重发、分支切换、失败重试与截断续写,对应 @xihan-ui/chat-stream 会话容器的 regenerate、edit、selectBranch、retry 与 continue;分支位置取快照的 branches,见 AI 对话。
  • 加载更早的消息使用无限滚动,必须把消息流的滚动容器交给它,否则它的提前量只对窗口视口生效。
  • 空会话使用空状态,并显式把它的 live 设为 off:它默认会成为活动区域,放在消息流中会与播报区冲突。
  • 需要左右分侧或气泡时,条目上带 data-role(user / assistant / system),在自己的样式表中按它编写 align-self、底色、内衬与最大行宽,组件不预设这层外观。
  • 仍在流式写入的条目带 data-streaming,这是留给使用者的钩子:正文经流式正文渲染时,光标就是“仍在写入”的标记;正文不经它渲染时,可按该属性自行添加非遮蔽式的标记,例如前导色条或标签态。

最佳实践 ​

  • 条目与分隔都必须是内容层的直接子节点:向上插入历史消息时的滚动补偿只在直接子节点中选锚点,套一层壳或使用 display: contents 都会让补偿静默失效。
  • 一轮流式结束时把整段最终文本写入播报区,不每个 token 写一次。

反模式 ​

  • 给每条消息各写 tabindex="0":两百条消息就是两百个 Tab 停靠位。
  • 在消息流内再套一层滚动容器:粘底逻辑只识别本组件的视口,套一层后失效。
  • 按 data-streaming 把整条消息压暗或虚化:一轮流式可能持续数分钟,被遮盖的正是读者正在逐字阅读的内容。

API 参考 ​

产物 ​

层值
自定义元素<xh-message-feed>
Vue 组件XhMessageFeedItem XhMessageFeedItemLabel XhMessageFeedList XhMessageFeedLiveRegion XhMessageFeedPendingIndicator XhMessageFeedRoot XhMessageFeedScrollToEndTrigger XhMessageFeedSeparator XhMessageFeedUnreadCount XhMessageFeedViewport
组合式函数useMessageFeed
状态机messageFeedMachine
皮肤@xihan-ui/styles/message-feed.css

Props ​

属性类型必填说明
countnumber消息总数,由宿主声明,不从 DOM 统计;aria-setsize 取它。
statusMessageFeedStatus本轮的运行态,只写 data-state,状态机不读取它。
thresholdnumber距底部多少 px 视为在底部,默认使用贴底原语的默认值。
loopboolean到达首尾是否回绕,默认 false:会话是线性的。
sizeSize尺寸:sm / md / lg;回到底部钮比它低一档、最低 sm(32 / 32 / 40px)。
translationsPartial<MessageFeedTranslations>
onStickChange(details: MessageFeedStickChangeDetails) => void
onItemFocus(details: MessageFeedItemFocusDetails) => void

事件 ​

自定义元素将载荷放在 detail;Vue 使用同名 emit。

事件载荷说明
stick-changeMessageFeedStickChangeDetails贴底状态变化;detail 为 { atBottom: boolean, sticking: boolean }
item-focusMessageFeedItemFocusDetails锚点变化;detail 为 { id: string | null }

插槽 ​

仅列出带载荷的插槽。

Vue 组件插槽载荷说明
XhMessageFeedRootdefaultMessageFeedRootSlotProps

React 适配器 props ​

只列各组件自己声明的那些:继承自 ComponentPropsWithRef 的 DOM 属性不在其中,根组件上与上面 Props 表同名的也不重复列。Vue 的对应物是上面的插槽表。

React 组件属性类型必填说明
XhMessageFeedItemitemIdstring是该条消息的身份,写为 data-value;导航与锚点都以它为准。
XhMessageFeedItemitemIndexnumber | string是0 基下标,写为 aria-posinset = index + 1。
XhMessageFeedItemitemRoleMessageFeedItemRole该条消息的发言者。
XhMessageFeedItemitemStreamingboolean该条仍在流式写入。
XhMessageFeedRootchildrenSlotChildren<MessageFeedRootSlotProps>

状态 ​

公开状态写入 data-state。

部件取值
rootprops.status
pending-indicatorprops.status
scroll-to-end-trigger'hidden' | 'visible'

以下名称仅用于内部状态机。

状态:idle

事件:STICK.CHANGE · SCROLL_TO_BOTTOM · ITEM.FOCUS · FEED.BLUR · PRESS.START · PRESS.END · ARRIVALS.TRACKED · TRIGGER.RENDERED

判据:canPress

connect API ​

getXxxProps() 返回对应部件的宿主属性。

成员类型说明
statusMessageFeedStatus
atBottomboolean
stickingboolean
focusedIdstring | nullroving tabindex 的锚点。
showScrollToEndTriggerboolean是否显示回到底部按钮:只判断是否在底部,不判断贴附意图。
unreadCountnumber离底期间新到的消息条数,回到底部即清零。按 count 的增长算,count 缺席时恒为 0。
scrollToBottom() => void
scrollToItem(id: string) => void把某条消息滚进可视区;该条不在 DOM 中时不做任何事。
focusItem(id: string) => void把焦点落到某条消息上;该条不在 DOM 中时不做任何事。
getRootProps() => T['element']
getViewportProps() => T['element']
getListProps() => T['element']
getItemProps(props: MessageFeedItemProps) => T['element']
getItemLabelProps(props: Pick<MessageFeedItemProps, 'id'>) => T['element']
getScrollToEndTriggerProps() => T['button']
getUnreadCountProps() => T['element']回到底部按钮上的未读数:没有未读时带 hidden,对读屏隐藏(条数已在按钮的可访问名里)。
getSeparatorProps() => T['element']消息之间的分隔(按日期分组的「今天」「9 月 27 日」这类):是内容层的直接子节点、与条目平级, 对读屏隐藏——role=feed 只认 article 子节点,时间由消息自己的时间戳表达。
getPendingIndicatorProps() => T['element']已发送、等首个片段时的呼吸点:status 为 submitted 时出现,对读屏隐藏。
getLiveRegionProps() => T['element']

无障碍 ​

键盘 ​

规格出处:W3C APG

按键生效条件行为
PageDown焦点在消息流内焦点移到下一条消息;到末条时按 loop 决定回绕还是不动
PageUp焦点在消息流内焦点移到上一条消息;到首条时按 loop 决定回绕还是不动
Control+End焦点在消息流内焦点移到消息流之后的第一个可聚焦元素,会话界面里通常是输入框
Control+Home焦点在消息流内焦点移到消息流之前的最后一个可聚焦元素
Tab焦点在消息流内外之间移动整份消息列表只占一个 Tab 停靠位:没有锚点时由根容器认领并把焦点转投给第一条,有锚点时那一条认领、根容器让位
ArrowUp / ArrowDown / Home / End焦点落在某条消息上组件不接管,浏览器滚动最近的可滚动祖先
Enter / Space焦点在回到底部按钮上滚回底部并恢复粘附(原生按钮激活)
Enter / Space按住回到底部按钮且视口不在底部按住期间 scroll-to-end-trigger 投影 data-pressed,与指针 :active 同一副按压面;抬起、失焦或回到底部(按钮收起)撤下

ARIA ​

以下属性由 connect 生成。

部件属性值
listaria-labeltranslations.feed
listrole'feed'
itemaria-labelundefined | itemLabel(item.index + 1, count ?? -1, item.role)
itemaria-labelledbyscope.partId('message-feed', item-label:${item.id}) | undefined
itemaria-posinsetitem.index + 1
itemaria-setsizeprops.count
itemrole'article'
separatoraria-hidden'true'
pending-indicatoraria-hidden'true'
scroll-to-end-triggeraria-labeltranslations.scrollToBottomUnread(unreadCount) | translations.scrollToBottom
unread-countaria-hidden'true'
live-regionaria-atomic'true'
live-regionaria-live'polite'
  • role=feed 配 role=article,带 aria-posinset / aria-setsize;总数由 count 声明,不从 DOM 计数,虚拟化或分页时 DOM 中的条数不等于会话长度。
  • 集合语义落在内容层而不是最外层:role=feed 只识别 role=article 的子节点,而播报区与回到底部按钮都是最外层的子节点。最外层只作为 Tab 停靠点与键盘宿主。
  • 播报使用独立的原子区域:一份会话只应有一个活动区域,每条消息各开一个会互相打断。
  • 消息流本身不发 aria-busy:它会压制同一棵子树内播报区的播报。
  • 等首个片段的呼吸点对读屏隐藏,不单靠动画表达状态。

样式参考 ​

皮肤 ​

@xihan-ui/styles/message-feed.css 按 [data-scope="message-feed"][data-part="root"] 部件选择器书写,发布产物以挂载类 .xh-scope-message-feed 代替其中的 data-scope(特异性相同),位于 xihan.components 层。覆盖样式使用 xihan.overrides,部件选择器照常可用。

forced-colors: active 下另有一套规则:颜色交给系统,边框与状态标记改用系统色关键字。

数据属性 ​

由 connect 生成;条件不成立时不输出无值属性。

部件属性值
rootdata-sizeprops.size
rootdata-stateprops.status
listdata-instant''(条件成立时才出现)
itemdata-roleitem.role
itemdata-streaming''(条件成立时才出现)
pending-indicatordata-stateprops.status
scroll-to-end-triggerdata-pressed''(条件成立时才出现)
scroll-to-end-triggerdata-state'hidden' | 'visible'
scroll-to-end-triggerdata-xh-action-control''
scroll-to-end-triggerdata-xh-action-display'always'
scroll-to-end-triggerdata-xh-action-profile'floating'
scroll-to-end-triggerdata-xh-action-sizefloatingSizeBelow(prop('size'))
scroll-to-end-triggerdata-xh-action-variant'ghost'
scroll-to-end-triggerdata-xh-liquid''
scroll-to-end-triggerdata-xh-material'frosted'

CSS 变量 ​

本组件公开覆盖槽由独立皮肤的实际消费位生成;默认来源、作用部件和状态均与 CSS 同源。

变量部件CSS 属性状态默认来源说明
--xh-message-feed-gaplistgapdefault--xh-_message-feed-gapmessage-feed 的 list 部件 gap 覆盖槽。
--xh-message-feed-icon-sizescroll-to-end-trigger--xh-icon-sizedefault--xh-_action-profile-glyph-sizemessage-feed 的 scroll-to-end-trigger 部件 --xh-icon-size 覆盖槽。
--xh-message-feed-item-gapitemgapdefault--xh-space-1message-feed 的 item 部件 gap 覆盖槽。
--xh-message-feed-item-radiusitemborder-radiusdefault--xh-shape-surfacemessage-feed 的 item 部件 border-radius 覆盖槽。
--xh-message-feed-label-fgitem-labelcolordefault--xh-fg-mutedmessage-feed 的 item-label 部件 color 覆盖槽。
--xh-message-feed-label-font-sizeitem-labelfont-sizedefault--xh-text-caption-sizemessage-feed 的 item-label 部件 font-size 覆盖槽。
--xh-message-feed-plist
pending-indicator
margin-block-end
margin-inline-start
padding
default--xh-_message-feed-pmessage-feed 的 list、pending-indicator 部件 margin-block-end、margin-inline-start、padding 覆盖槽。
--xh-message-feed-pending-indicator-colorpending-indicatorbackgrounddefault--xh-fg-mutedmessage-feed 的 pending-indicator 部件 background 覆盖槽。
--xh-message-feed-pending-indicator-radiuspending-indicatorborder-radiusdefault--xh-shape-circlemessage-feed 的 pending-indicator 部件 border-radius 覆盖槽。
--xh-message-feed-pending-indicator-sizepending-indicatorblock-size
inline-size
default--xh-space-2message-feed 的 pending-indicator 部件 block-size、inline-size 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-bgscroll-to-end-trigger--xh-ink-surface
background-color
default
disabled
focus-visible
xh-ink-surface
--xh-_material-bg
--xh-_material-bg-focus
message-feed 的 scroll-to-end-trigger 部件 --xh-ink-surface、background-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-bg-hoverscroll-to-end-triggerbackground-colordisabled
hover
loading
not([data-disabled])
not([data-loading])
--xh-_material-bg-hovermessage-feed 的 scroll-to-end-trigger 部件 background-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-borderscroll-to-end-triggerborder
border-color
default
disabled
focus-visible
hover
is(:active, [data-pressed])
loading
not([data-disabled])
not([data-loading])
pressed
--xh-_material-bordermessage-feed 的 scroll-to-end-trigger 部件 border、border-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-fgscroll-to-end-triggercolordefault
disabled
focus-visible
hover
is(:active, [data-pressed])
loading
not([data-disabled])
not([data-loading])
pressed
--xh-_material-fgmessage-feed 的 scroll-to-end-trigger 部件 color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-insetscroll-to-end-triggerinset-block-end
inset-inline-end
default--xh-space-4message-feed 的 scroll-to-end-trigger 部件 inset-block-end、inset-inline-end 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-radiusscroll-to-end-triggerborder-radiusdefault--xh-shape-circlemessage-feed 的 scroll-to-end-trigger 部件 border-radius 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-shadowscroll-to-end-triggerbox-shadowdefault
disabled
focus-visible
hover
is(:active, [data-pressed])
is(:hover, [data-loading])
loading
not([data-disabled])
not([data-loading])
pressed
--xh-_material-shadowmessage-feed 的 scroll-to-end-trigger 部件 box-shadow 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-sizescroll-to-end-triggerblock-size
inline-size
default
xh-action-profile=floating
--xh-_action-profile-visual-sizemessage-feed 的 scroll-to-end-trigger 部件 block-size、inline-size 覆盖槽。
--xh-message-feed-separator-fgseparatorcolordefault--xh-fg-subtlemessage-feed 的 separator 部件 color 覆盖槽。
--xh-message-feed-separator-font-sizeseparatorfont-sizedefault--xh-text-caption-sizemessage-feed 的 separator 部件 font-size 覆盖槽。
--xh-message-feed-separator-gapseparatorgapdefault--xh-space-3message-feed 的 separator 部件 gap 覆盖槽。
--xh-message-feed-separator-lineseparatorborder-block-startdefault--xh-border-subtlemessage-feed 的 separator 部件 border-block-start 覆盖槽。
--xh-message-feed-unread-count-bgunread-countbackgrounddefault--xh-bg-brandmessage-feed 的 unread-count 部件 background 覆盖槽。
--xh-message-feed-unread-count-fgunread-countcolordefault--xh-fg-on-brandmessage-feed 的 unread-count 部件 color 覆盖槽。
--xh-message-feed-unread-count-font-sizeunread-countfont-sizedefault--xh-text-caption-sizemessage-feed 的 unread-count 部件 font-size 覆盖槽。
--xh-message-feed-unread-count-pxunread-countpadding-inlinedefault--xh-space-1message-feed 的 unread-count 部件 padding-inline 覆盖槽。
--xh-message-feed-unread-count-radiusunread-countborder-radiusdefault--xh-shape-pillmessage-feed 的 unread-count 部件 border-radius 覆盖槽。
--xh-message-feed-unread-count-sizeunread-countblock-size
line-height
min-inline-size
default--xh-space-4message-feed 的 unread-count 部件 block-size、line-height、min-inline-size 覆盖槽。

动效 ​

动效角色:按压 · 状态 · 出现 · 列表 · 循环(见动效规范)。

共享关键帧 xh-breathe · xh-breathe-halo · xh-item-in · xh-overlay-pop-in · xh-pop-out 由 family/motion.css 提供,皮肤 @import 它,单独引入仍成立。时长与缓动读动效令牌,改令牌即改全局节奏。

prefers-reduced-motion: reduce 下本组件另有降级规则。

RTL ​

皮肤用逻辑属性排布(inline-start 一族),dir="rtl" 下自动镜像。

Released under The MIT License