Message Scroller 消息滚动区

用于消息滚动区场景的 MoriUI 组件。

导入

import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility } from 'moriui'

示例

概览

标准消息滚动容器。

新对话
今天有什么可以帮助你的?
开始对话

输入消息并按发送开始对话。

演示为只读预览。输入消息开始对话。
<script setup lang="ts">
  import { ref } from 'vue'
  import { ArrowUp, Inbox, RotateCw } from '@lucide/vue'
  import {
    Button,
    Card,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
    Empty,
    EmptyDescription,
    EmptyHeader,
    EmptyMedia,
    EmptyTitle,
    InputGroup,
    InputGroupButton,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport
  } from 'moriui'

  interface ChatMessage {
    id: string
    role: 'user' | 'assistant'
    text: string
  }

  const messages = ref<ChatMessage[]>([])
  const inputText = ref('')
  const isBusy = ref(false)

  function sendMessage() {
    const text = inputText.value.trim()
    if (!text || isBusy.value)
      return

    inputText.value = ''
    const userMsg: ChatMessage = {
      id: `msg-${Date.now()}`,
      role: 'user',
      text
    }
    messages.value = [...messages.value, userMsg]
    simulateReply()
  }

  function simulateReply() {
    isBusy.value = true
    setTimeout(() => {
      const assistantMsg: ChatMessage = {
        id: `msg-${Date.now()}`,
        role: 'assistant',
        text: getDemoReply(messages.value.length)
      }
      messages.value = [...messages.value, assistantMsg]
      isBusy.value = false
    }, 800)
  }

  function resetConversation() {
    messages.value = []
    isBusy.value = false
  }

  function getDemoReply(count: number): string {
    const replies = [
      '这是一个很好的问题。`MessageScroller` 会自动管理滚动位置,让新消息始终可见。',
      '当用户滚动查看历史消息时,自动滚动会暂停,不会打扰阅读体验。',
      `你可以自定义滚动锚点,让特定消息在视图顶部附近固定显示。`,
      '`MessageScrollerButton` 会在新消息到达时出现在底部,点击即可跳转到最新消息。'
    ]
    return replies[(count - 1) % replies.length]
  }
</script>

<template>
  <MessageScrollerProvider>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>新对话</CardTitle>
          <CardDescription>今天有什么可以帮助你的?</CardDescription>
          <div class="ms-auto">
            <Button
              variant="outline"
              size="icon"
              aria-label="重置对话"
              :disabled="messages.length === 0 || isBusy"
              @click="resetConversation"
            >
              <RotateCw />
            </Button>
          </div>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <div v-if="messages.length === 0" class="h-full">
            <Empty class="h-full">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <Inbox class="size-5" />
                </EmptyMedia>
                <EmptyTitle>开始对话</EmptyTitle>
                <EmptyDescription>
                  输入消息并按发送开始对话。
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          </div>
          <div v-else class="h-full">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-(--card-spacing)">
                  <MessageScrollerItem
                    v-for="message in messages"
                    :key="message.id"
                    :message-id="message.id"
                    :scroll-anchor="message.role === 'user'"
                    class="flex flex-col gap-2"
                  >
                    <div
                      class="rounded-2xl px-4 py-2.5 text-sm max-w-[80%]"
                      :class="[
                        message.role === 'user'
                          ? 'bg-primary text-primary-foreground ms-auto'
                          : 'bg-muted text-muted-foreground me-auto',
                      ]"
                    >
                      {{ message.text }}
                    </div>
                  </MessageScrollerItem>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </div>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <form class="w-full flex gap-2" @submit.prevent="sendMessage">
            <InputGroup class="w-full">
              <input
                v-model="inputText"
                type="text"
                placeholder="输入消息..."
                :disabled="isBusy"
                class="flex h-10 w-full rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
              >
              <InputGroupButton
                type="submit"
                variant="default"
                size="icon-sm"
                :disabled="!inputText.trim() || isBusy"
                class="ms-auto"
              >
                <ArrowUp />
                <span class="sr-only">发送</span>
              </InputGroupButton>
            </InputGroup>
          </form>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        演示为只读预览。输入消息开始对话。
      </div>
    </div>
  </MessageScrollerProvider>
</template>

可滚动

长消息列表滚动。

滚动状态
根据当前滚动位置显示读者可以滚动的方向。

回顾滚动检查点 1。

检查点 2 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

回顾滚动检查点 3。

检查点 4 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

回顾滚动检查点 5。

检查点 6 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

回顾滚动检查点 7。

检查点 8 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

回顾滚动检查点 9。

检查点 10 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

回顾滚动检查点 11。

检查点 12 已同步。滚动钩子会在视口移动时更新。

当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。

在最新消息处,提示应再次切换,仅指示向上滚动。

滚动对话记录查看底部更新。
<script setup lang="ts">
  import { computed, defineComponent, h } from 'vue'
  import {
    Card,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport,
    useMessageScrollerScrollable
  } from 'moriui'

  interface ScrollMessage {
    id: string
    role: 'user' | 'assistant'
    text: string
  }

  const messages: ScrollMessage[] = Array.from({ length: 12 }, (_, index) => ({
    id: `scrollable-${index + 1}`,
    role: index % 2 === 0 ? 'user' : 'assistant',
    text: index % 2 === 0
      ? `回顾滚动检查点 ${index + 1}。`
      : `检查点 ${index + 1} 已同步。滚动钩子会在视口移动时更新。\n\n当读者位于第一条消息时,底部提示应仅指示向下滚动。一旦进入对话中间,提示应说明两个方向均可。\n\n在最新消息处,提示应再次切换,仅指示向上滚动。`
  }))

  function getScrollStatus({ start, end }: { start: boolean, end: boolean }): string {
    if (start && end)
      return '两个方向都可以滚动。'
    if (end)
      return '已到达顶部,只能向下滚动。'
    if (start)
      return '已到达底部,只能向上滚动。'
    return '所有消息都适配在视口内。'
  }

  const ScrollStateFooter = defineComponent({
    setup() {
      const { start, end } = useMessageScrollerScrollable()
      const status = computed(() => getScrollStatus({ start: start.value, end: end.value }))

      return () => h(CardFooter, { class: 'justify-center border-t text-center text-sm text-muted-foreground' }, () => status.value)
    }
  })
</script>

<template>
  <div class="mx-auto flex w-full max-w-sm flex-col gap-4">
    <Card class="h-140 w-full gap-0 overflow-hidden">
      <CardHeader class="gap-1 border-b">
        <CardTitle>滚动状态</CardTitle>
        <CardDescription>
          根据当前滚动位置显示读者可以滚动的方向。
        </CardDescription>
      </CardHeader>
      <MessageScrollerProvider default-scroll-position="start">
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent class="gap-4 p-(--card-spacing)">
                <MessageScrollerItem
                  v-for="message in messages"
                  :key="message.id"
                  :message-id="message.id"
                  :scroll-anchor="message.role === 'user'"
                  class="flex flex-col gap-2"
                >
                  <div
                    class="rounded-2xl px-4 py-2.5 text-sm max-w-[80%]"
                    :class="[
                      message.role === 'user'
                        ? 'bg-primary text-primary-foreground ms-auto'
                        : 'bg-muted text-muted-foreground me-auto',
                    ]"
                  >
                    <p
                      v-for="(paragraph, pIndex) in message.text.split('\n\n')"
                      :key="pIndex"
                      class="whitespace-pre-wrap"
                    >
                      {{ paragraph }}
                    </p>
                  </div>
                </MessageScrollerItem>
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <ScrollStateFooter />
      </MessageScrollerProvider>
    </Card>
    <div class="px-0.5 text-center text-xs text-muted-foreground">
      滚动对话记录查看底部更新。
    </div>
  </div>
</template>

锚定

新消息自动滚动对齐。

锚定对话轮次
选择哪个角色在顶部边缘附近固定显示。
暂无锚定消息

发送第一条消息查看所选角色的锚定效果。

切换锚定角色,然后发送消息对比轮次的固定位置。
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { ArrowUp, Inbox, RotateCw } from '@lucide/vue'
  import {
    Button,
    Card,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
    Empty,
    EmptyDescription,
    EmptyHeader,
    EmptyMedia,
    EmptyTitle,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport,
    ToggleGroup,
    ToggleGroupItem
  } from 'moriui'

  type AnchorRole = 'user' | 'assistant'

  interface ChatMessage {
    id: string
    role: AnchorRole
    text: string
  }

  const anchorRole = ref<AnchorRole>('user')
  const messages = ref<ChatMessage[]>([])
  const messageIndex = ref(0)

  const scriptedMessages: ChatMessage[] = [
    {
      id: 'anchor-1-user',
      role: 'user',
      text: '能展示一下当新提示开始时锚定行为是怎样的吗?'
    },
    {
      id: 'anchor-1-assistant',
      role: 'assistant',
      text: '用户提示会先追加,然后追加助理回复。选择"用户"时,提示会稳定在顶部附近,助理回复在其下方填充。'
    },
    {
      id: 'anchor-2-user',
      role: 'user',
      text: '如果助理消息作为锚点会有什么变化?'
    },
    {
      id: 'anchor-2-assistant',
      role: 'assistant',
      text: '每条助理回复都是 `MessageScroller` 保持可见的项目。当回复是你希望读者在每次对话后落脚的位置时,这非常有用。'
    },
    {
      id: 'anchor-3-user',
      role: 'user',
      text: '我能在切换角色后继续添加对话吗?'
    },
    {
      id: 'anchor-3-assistant',
      role: 'assistant',
      text: '可以。下一个追加的所选角色消息会成为锚点,因此你无需重置演示即可比较用户和助理锚定。'
    }
  ]

  const nextMessage = computed(() => scriptedMessages[messageIndex.value])

  function sendMessage() {
    if (!nextMessage.value)
      return
    messages.value = [...messages.value, nextMessage.value]
    messageIndex.value++
  }

  function resetMessages() {
    messages.value = []
    messageIndex.value = 0
  }

  function changeAnchorRole(value: string[]) {
    const nextValue = value[0]
    if (nextValue === 'user' || nextValue === 'assistant') {
      anchorRole.value = nextValue
      messages.value = []
      messageIndex.value = 0
    }
  }
</script>

<template>
  <div class="relative flex flex-col gap-4">
    <Card class="mx-auto h-140 w-full max-w-sm gap-0">
      <CardHeader class="border-b">
        <CardTitle>锚定对话轮次</CardTitle>
        <CardDescription>
          选择哪个角色在顶部边缘附近固定显示。
        </CardDescription>
        <div class="ms-auto">
          <Button
            type="button"
            variant="outline"
            size="icon"
            aria-label="重置锚定消息"
            :disabled="messages.length === 0"
            @click="resetMessages"
          >
            <RotateCw />
          </Button>
        </div>
      </CardHeader>
      <CardContent class="min-h-0 flex-1 overflow-hidden p-0">
        <div v-if="messages.length === 0" class="h-full">
          <Empty class="h-full">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Inbox class="size-5" />
              </EmptyMedia>
              <EmptyTitle>暂无锚定消息</EmptyTitle>
              <EmptyDescription>
                发送第一条消息查看所选角色的锚定效果。
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        </div>
        <div v-else class="h-full">
          <MessageScrollerProvider>
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-(--card-spacing)">
                  <MessageScrollerItem
                    v-for="message in messages"
                    :key="message.id"
                    :message-id="message.id"
                    :scroll-anchor="message.role === anchorRole"
                    class="flex flex-col gap-2"
                  >
                    <div
                      class="rounded-2xl px-4 py-2.5 text-sm max-w-[80%]"
                      :class="[
                        message.role === 'user'
                          ? 'bg-primary text-primary-foreground ms-auto'
                          : 'bg-muted text-muted-foreground me-auto',
                      ]"
                    >
                      {{ message.text }}
                    </div>
                  </MessageScrollerItem>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </MessageScrollerProvider>
        </div>
      </CardContent>
      <CardFooter>
        <ToggleGroup
          aria-label="选择滚动锚定角色"
          :model-value="[anchorRole]"
          @update:model-value="changeAnchorRole"
        >
          <ToggleGroupItem value="user" aria-label="锚定用户消息">
            用户
          </ToggleGroupItem>
          <ToggleGroupItem value="assistant" aria-label="锚定助理消息">
            助理
          </ToggleGroupItem>
        </ToggleGroup>
        <Button
          type="button"
          size="icon"
          class="ms-auto"
          :disabled="!nextMessage"
          @click="sendMessage"
        >
          <ArrowUp />
          <span class="sr-only">发送消息</span>
        </Button>
      </CardFooter>
    </Card>
    <div class="mx-auto max-w-xs px-0.5 text-center text-xs text-muted-foreground">
      切换锚定角色,然后发送消息对比轮次的固定位置。
    </div>
  </div>
</template>

流式

渐进追加流式消息。

流式消息
自动滚动跟随对话的最新边缘。
准备流式播放

点击发送按钮查看模拟的流式对话。

流式为模拟效果。已启用 `autoScroll`。
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { ArrowUp, Inbox, RotateCw } from '@lucide/vue'
  import {
    Button,
    Card,
    CardAction,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
    Empty,
    EmptyDescription,
    EmptyHeader,
    EmptyMedia,
    EmptyTitle,
    InputGroup,
    InputGroupButton,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerProvider,
    MessageScrollerViewport
  } from 'moriui'

  interface ChatMessage {
    id: string
    role: 'user' | 'assistant'
    text: string
  }

  interface StreamStage {
    messages: ChatMessage[]
    pendingText?: string
  }

  const isStreaming = ref(false)

  const stages: StreamStage[] = [
    {
      messages: []
    },
    {
      messages: [
        { id: 's-1-user', role: 'user', text: '我在为我们的应用构建聊天功能,滚动行为让我很头疼。每次 AI 流式回复时,整个对话线程都在跳动。' }
      ]
    },
    {
      messages: [
        { id: 's-1-user', role: 'user', text: '我在为我们的应用构建聊天功能,滚动行为让我很头疼。每次 AI 流式回复时,整个对话线程都在跳动。' },
        { id: 's-1-assistant', role: 'assistant', text: '这就是经典的流式滚动问题。将消息列表包裹在 `MessageScroller` 中并启用 autoScroll——视口会固定在底部,让用户始终看到最新的文本内容。\n\n重要的是:它只在阅读器已经在底部时才自动滚动。当用户向上滚动查看之前的内容时,自动滚动会暂停,保留用户的阅读位置。' }
      ]
    },
    {
      messages: [
        { id: 's-1-user', role: 'user', text: '我在为我们的应用构建聊天功能,滚动行为让我很头疼。每次 AI 流式回复时,整个对话线程都在跳动。' },
        { id: 's-1-assistant', role: 'assistant', text: '这就是经典的流式滚动问题。将消息列表包裹在 `MessageScroller` 中并启用 autoScroll——视口会固定在底部,让用户始终看到最新的文本内容。\n\n重要的是:它只在阅读器已经在底部时才自动滚动。当用户向上滚动查看之前的内容时,自动滚动会暂停,保留用户的阅读位置。' },
        { id: 's-2-user', role: 'user', text: '但如果用户向上滚动查看之前的回复呢?我不想把他们拉回来。' }
      ]
    },
    {
      messages: [
        { id: 's-1-user', role: 'user', text: '我在为我们的应用构建聊天功能,滚动行为让我很头疼。每次 AI 流式回复时,整个对话线程都在跳动。' },
        { id: 's-1-assistant', role: 'assistant', text: '这就是经典的流式滚动问题。将消息列表包裹在 `MessageScroller` 中并启用 autoScroll——视口会固定在底部,让用户始终看到最新的文本内容。\n\n重要的是:它只在阅读器已经在底部时才自动滚动。当用户向上滚动查看之前的内容时,自动滚动会暂停,保留用户的阅读位置。' },
        { id: 's-2-user', role: 'user', text: '但如果用户向上滚动查看之前的回复呢?我不想把他们拉回来。' },
        { id: 's-2-assistant', role: 'assistant', text: '自动滚动只在视口固定在底部时运行,因此向上滚动是有意退出——即使用户未读的新消息不断到达,他们在对话中的位置也会保持不变。\n\n当有未读内容时,`MessageScrollerButton` 会出现在视口底部。点击即可跳转到最新消息并重新启用自动滚动。' }
      ]
    }
  ]

  const currentStage = ref(0)
  const messages = ref<ChatMessage[]>([])

  const isBusy = computed(() => isStreaming.value)
  const hasNext = computed(() => currentStage.value < stages.length - 1)

  function advanceStage() {
    if (currentStage.value >= stages.length - 1)
      return
    isStreaming.value = true
    currentStage.value++

    // Simulate streaming by showing the next stage after a delay
    setTimeout(() => {
      messages.value = stages[currentStage.value].messages
      isStreaming.value = false
    }, 600)
  }

  function resetStream() {
    currentStage.value = 0
    messages.value = []
    isStreaming.value = false
  }
</script>

<template>
  <MessageScrollerProvider auto-scroll>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>流式消息</CardTitle>
          <CardDescription>
            自动滚动跟随对话的最新边缘。
          </CardDescription>
          <CardAction>
            <Button
              variant="outline"
              size="icon"
              aria-label="重置流式演示"
              :disabled="messages.length === 0 || isBusy"
              @click="resetStream"
            >
              <RotateCw />
            </Button>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <div v-if="messages.length === 0" class="h-full">
            <Empty class="h-full">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <Inbox class="size-5" />
                </EmptyMedia>
                <EmptyTitle>准备流式播放</EmptyTitle>
                <EmptyDescription>
                  点击发送按钮查看模拟的流式对话。
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          </div>
          <div v-else class="h-full">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent
                  :aria-busy="isBusy.toString()"
                  class="p-(--card-spacing)"
                >
                  <MessageScrollerItem
                    v-for="message in messages"
                    :key="message.id"
                    :message-id="message.id"
                    :scroll-anchor="message.role === 'user'"
                    class="flex flex-col gap-2"
                  >
                    <div
                      class="rounded-2xl px-4 py-2.5 text-sm max-w-[80%] whitespace-pre-wrap"
                      :class="[
                        message.role === 'user'
                          ? 'bg-primary text-primary-foreground ms-auto'
                          : 'bg-muted text-muted-foreground me-auto',
                      ]"
                    >
                      {{ message.text }}
                    </div>
                  </MessageScrollerItem>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </div>
        </CardContent>
        <CardFooter class="flex-col gap-2">
          <InputGroup class="w-full">
            <div class="flex h-14 w-full items-center px-3 py-2.5">
              <span
                class="line-clamp-2 text-sm opacity-60"
                :class="{ 'opacity-100': !hasNext }"
              >
                <span v-if="hasNext" class="text-muted-foreground">
                  点击发送添加下一组消息。
                </span>
                <span v-else class="text-muted-foreground">
                  演示已结束。重置可重新播放。
                </span>
              </span>
            </div>
            <InputGroupButton
              type="button"
              variant="default"
              size="icon-sm"
              :disabled="!hasNext || isBusy"
              class="ms-auto"
              @click="advanceStage"
            >
              <ArrowUp />
              <span class="sr-only">发送</span>
            </InputGroupButton>
          </InputGroup>
        </CardFooter>
      </Card>
      <div class="px-0.5 text-center text-xs text-muted-foreground">
        流式为模拟效果。已启用 `autoScroll`。
      </div>
    </div>
  </MessageScrollerProvider>
</template>

加载历史

回溯加载旧消息。

加载历史
前置追加的消息会保留滚动位置。

只修改了导出队列工作者。此次部署将大型 CSV 作业移到了共享重试策略上,这使得每次失败尝试占用的工作线程时间比以前更长。

应用部署不包括结账、定价或计费 API 的更改。

我们需要回滚吗?

暂时不用。在我们降低重试并发后,队列深度正在恢复,最旧的待处理作业现在不到五分钟。

如果队列再次开始攀升,请准备好回滚,但当前趋势指向恢复。

继续关注客户可见的问题。

我将继续关注队列和支持标签 15 分钟。我会跟踪导出失败、延迟的下载请求以及任何提到缺失报告的支持线程。

如果这些问题在下一个批次窗口期间保持平静,我们可以将此事件关闭为内部降级。

对话结束
点击加载历史按钮查看完整对话。
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { RotateCw } from '@lucide/vue'
  import {
    Bubble,
    BubbleContent,
    Button,
    Card,
    CardAction,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
    Marker,
    MarkerContent,
    Message,
    MessageContent,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport
  } from 'moriui'

  interface HistoryMessage {
    id: string
    role: 'user' | 'assistant'
    text: string
  }

  const history: HistoryMessage[] = [
    { id: 'h-1-user', role: 'user', text: '能否总结一下事故频道的情况?' },
    { id: 'h-1-assistant', role: 'assistant', text: '第一个警报是延迟的导出作业。它大约在 09:42 UTC 开始积压,一旦重试队列超过阈值就触发了警告。\n\n没有客户结账路径受到影响,但较大工作区的导出运行延迟了约 12 分钟。' },
    { id: 'h-2-user', role: 'user', text: '结账功能受到影响了吗?' },
    { id: 'h-2-assistant', role: 'assistant', text: '没有报告结账错误。支付授权、订单创建和确认邮件都保持在正常延迟范围内。\n\n唯一升高的指标是导出队列深度,这对应的是分析下载而不是结账。' },
    { id: 'h-3-user', role: 'user', text: '上次部署改了什么?' },
    { id: 'h-3-assistant', role: 'assistant', text: '只修改了导出队列工作者。此次部署将大型 CSV 作业移到了共享重试策略上,这使得每次失败尝试占用的工作线程时间比以前更长。\n\n应用部署不包括结账、定价或计费 API 的更改。' },
    { id: 'h-4-user', role: 'user', text: '我们需要回滚吗?' },
    { id: 'h-4-assistant', role: 'assistant', text: '暂时不用。在我们降低重试并发后,队列深度正在恢复,最旧的待处理作业现在不到五分钟。\n\n如果队列再次开始攀升,请准备好回滚,但当前趋势指向恢复。' },
    { id: 'h-5-user', role: 'user', text: '继续关注客户可见的问题。' },
    { id: 'h-5-assistant', role: 'assistant', text: '我将继续关注队列和支持标签 15 分钟。我会跟踪导出失败、延迟的下载请求以及任何提到缺失报告的支持线程。\n\n如果这些问题在下一个批次窗口期间保持平静,我们可以将此事件关闭为内部降级。' }
  ]

  const INITIAL_VISIBLE_COUNT = 5
  const visibleCount = ref(INITIAL_VISIBLE_COUNT)
  const isLoadingMessage = ref('')
  const demoKey = ref(0)

  const visibleMessages = computed(() => history.slice(-visibleCount.value))
  const canLoadHistory = computed(() => visibleCount.value < history.length)

  function loadHistory() {
    visibleCount.value = history.length
    isLoadingMessage.value = '已加载全部历史记录,向上滚动查看早期消息。'
    setTimeout(() => {
      isLoadingMessage.value = ''
    }, 3000)
  }

  function resetHistory() {
    visibleCount.value = INITIAL_VISIBLE_COUNT
    isLoadingMessage.value = ''
    demoKey.value++
  }
</script>

<template>
  <MessageScrollerProvider>
    <div class="relative flex flex-col gap-4">
      <Card class="mx-auto h-140 w-full max-w-sm gap-0">
        <CardHeader class="gap-1 border-b">
          <CardTitle>加载历史</CardTitle>
          <CardDescription>
            前置追加的消息会保留滚动位置。
          </CardDescription>
          <CardAction>
            <Button
              type="button"
              variant="outline"
              size="icon"
              aria-label="重置已加载消息"
              :disabled="visibleCount === INITIAL_VISIBLE_COUNT"
              @click="resetHistory"
            >
              <RotateCw />
            </Button>
          </CardAction>
        </CardHeader>
        <CardContent class="flex-1 overflow-hidden p-0">
          <MessageScroller :key="`history-${demoKey}`">
            <MessageScrollerViewport>
              <MessageScrollerContent class="p-(--card-spacing)">
                <MessageScrollerItem
                  v-for="message in visibleMessages"
                  :key="message.id"
                  :message-id="message.id"
                  class="flex flex-col gap-2"
                >
                  <Message :align="message.role === 'user' ? 'end' : 'start'">
                    <MessageContent>
                      <Bubble :variant="message.role === 'user' ? 'muted' : 'ghost'">
                        <BubbleContent class="space-y-2">
                          <p
                            v-for="(paragraph, pIndex) in message.text.split('\n\n')"
                            :key="pIndex"
                            class="whitespace-pre-wrap text-sm"
                          >
                            {{ paragraph }}
                          </p>
                        </BubbleContent>
                      </Bubble>
                    </MessageContent>
                  </Message>
                </MessageScrollerItem>
                <MessageScrollerItem :scroll-anchor="false">
                  <Marker variant="separator">
                    <MarkerContent>对话结束</MarkerContent>
                  </Marker>
                </MessageScrollerItem>
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </CardContent>
        <CardFooter class="flex-col items-center gap-2 border-t">
          <Button
            type="button"
            :disabled="!canLoadHistory"
            class="w-full"
            variant="secondary"
            @click="loadHistory"
          >
            {{ canLoadHistory ? '加载历史记录' : '已加载全部' }}
          </Button>
          <p class="text-xs text-muted-foreground">
            {{ isLoadingMessage || '恢复早期消息同时保留滚动位置。' }}
          </p>
        </CardFooter>
      </Card>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground">
        点击加载历史按钮查看完整对话。
      </div>
    </div>
  </MessageScrollerProvider>
</template>

可见性

检测消息进入视口。

对话大纲
跟踪当前锚定的对话轮次。

请审查事故交接并告诉我应该先阅读什么。

先从摘要和影响部分开始。回归影响了上传队列,但每个排队作业的恢复路径都已完成。

客户受到了什么影响?

影响仅限于处理延迟。

没有记录丢失,对账工作者确认了每个重试批次。支持团队看到了两位客户的困惑,但没有结账或计费错误。

有哪些待办事项?

在下一次部署之前保持重试窗口启用,然后添加队列深度警报作为长期修复。

警报应在队列持续增长时触发,而不是针对单次短暂峰值。

给我后续检查清单。

之后,将队列恢复图与部署时间线进行比较,以便交接清楚地显示处理何时恢复到基线。这样支持团队和工程团队无需重新阅读整个事故线程即可回答相同的客户问题。

我还会在每个后续项目旁边添加简短的责任人备注。清单很小,但明确责任可以防止重试窗口决策、警报调整和支持宏漂移到单独的后续对话中。

打开大纲可在阅读时跳转到不同的锚定轮次。
<script setup lang="ts">
  import { defineComponent, h } from 'vue'
  import {
    Bubble,
    BubbleContent,
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
    HoverCard,
    HoverCardContent,
    HoverCardTrigger,
    Message,
    MessageContent,
    MessageScroller,
    MessageScrollerButton,
    MessageScrollerContent,
    MessageScrollerItem,
    MessageScrollerProvider,
    MessageScrollerViewport,
    useMessageScroller,
    useMessageScrollerVisibility
  } from 'moriui'

  interface VisibilityMessage {
    id: string
    role: 'user' | 'assistant'
    text: string
  }

  const messages: VisibilityMessage[] = [
    { id: 'vis-brief', role: 'user', text: '请审查事故交接并告诉我应该先阅读什么。' },
    { id: 'vis-brief-asst', role: 'assistant', text: '先从摘要和影响部分开始。回归影响了上传队列,但每个排队作业的恢复路径都已完成。' },
    { id: 'vis-impact', role: 'user', text: '客户受到了什么影响?' },
    { id: 'vis-impact-asst', role: 'assistant', text: '影响仅限于处理延迟。\n\n没有记录丢失,对账工作者确认了每个重试批次。支持团队看到了两位客户的困惑,但没有结账或计费错误。' },
    { id: 'vis-actions', role: 'user', text: '有哪些待办事项?' },
    { id: 'vis-actions-asst', role: 'assistant', text: '在下一次部署之前保持重试窗口启用,然后添加队列深度警报作为长期修复。\n\n警报应在队列持续增长时触发,而不是针对单次短暂峰值。' },
    { id: 'vis-checklist', role: 'user', text: '给我后续检查清单。' },
    { id: 'vis-checklist-asst', role: 'assistant', text: '之后,将队列恢复图与部署时间线进行比较,以便交接清楚地显示处理何时恢复到基线。这样支持团队和工程团队无需重新阅读整个事故线程即可回答相同的客户问题。\n\n我还会在每个后续项目旁边添加简短的责任人备注。清单很小,但明确责任可以防止重试窗口决策、警报调整和支持宏漂移到单独的后续对话中。' }
  ]

  const userMessages = messages.filter(m => m.role === 'user')

  const TranscriptOutline = defineComponent({
    setup() {
      const { scrollToMessage } = useMessageScroller()
      const { currentAnchorId } = useMessageScrollerVisibility()

      const dots = userMessages.map(message => {
        const isCurrent = currentAnchorId.value === message.id
        return h('span', {
          'key': message.id,
          'data-current': isCurrent ? '' : undefined,
          'class': [
            'h-0.5 w-4 rounded-full transition-colors',
            isCurrent ? 'bg-foreground' : 'bg-muted-foreground/40'
          ]
        })
      })

      const outlineItems = userMessages.map(message => {
        const isCurrent = currentAnchorId.value === message.id
        const text = message.text.length > 42 ? `${message.text.slice(0, 39)}...` : message.text
        return h('button', {
          'key': message.id,
          'type': 'button',
          'aria-current': isCurrent ? 'location' : undefined,
          'class': [
            'flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm transition-colors outline-none',
            'hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground',
            isCurrent ? 'bg-accent text-accent-foreground' : ''
          ],
          'onClick': () => scrollToMessage(message.id, { align: 'start', behavior: 'smooth' })
        }, text)
      })

      return () => h('div', { class: 'absolute top-1/2 -right-12 -translate-y-1/2' }, [
        h(HoverCard, {}, () => [
          h(HoverCardTrigger, { 'aria-label': '打开对话大纲' }, () =>
            h('button', {
              'type': 'button',
              'aria-label': '打开对话大纲',
              'class': 'flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50'
            }, dots)),
          h(HoverCardContent, {
            align: 'center',
            side: 'left',
            sideOffset: -28,
            class: 'flex w-64 flex-col gap-1 rounded-2xl p-1'
          }, () => outlineItems)
        ])
      ])
    }
  })
</script>

<template>
  <MessageScrollerProvider :scroll-margin="12">
    <div class="relative flex flex-col gap-4">
      <div class="relative mx-auto w-full max-w-sm">
        <Card class="h-140 w-full gap-0">
          <CardHeader class="gap-1 border-b">
            <CardTitle>对话大纲</CardTitle>
            <CardDescription>
              跟踪当前锚定的对话轮次。
            </CardDescription>
          </CardHeader>
          <CardContent class="flex-1 overflow-hidden p-0">
            <MessageScroller>
              <MessageScrollerViewport>
                <MessageScrollerContent class="p-(--card-spacing)">
                  <MessageScrollerItem
                    v-for="message in messages"
                    :key="message.id"
                    :message-id="message.id"
                    :scroll-anchor="message.role === 'user'"
                    class="flex flex-col gap-2"
                  >
                    <Message :align="message.role === 'user' ? 'end' : 'start'">
                      <MessageContent>
                        <Bubble :variant="message.role === 'user' ? 'muted' : 'ghost'">
                          <BubbleContent class="space-y-2">
                            <p
                              v-for="(paragraph, pIndex) in message.text.split('\n\n')"
                              :key="pIndex"
                              class="whitespace-pre-wrap text-sm"
                            >
                              {{ paragraph }}
                            </p>
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                </MessageScrollerContent>
              </MessageScrollerViewport>
              <MessageScrollerButton />
            </MessageScroller>
          </CardContent>
        </Card>
        <TranscriptOutline />
      </div>
      <div class="mx-auto max-w-sm px-0.5 text-center text-xs text-muted-foreground">
        打开大纲可在阅读时跳转到不同的锚定轮次。
      </div>
    </div>
  </MessageScrollerProvider>
</template>

使用说明

组合方式

MessageScroller 由 Provider、Viewport、Content、Item 和 Button 组成,支持滚动定位、锚定与可见性检测。

滚动定位

scrollAlign 控制新消息的滚动对齐方式。

流式追加

支持渐进式插入新消息,保持滚动位置稳定。

加载历史

回溯加载历史消息,不改变当前视口位置。

变体

MessageScroller 的 scrollAlign 支持 start/end;defaultScrollPosition 支持 top/bottom。

可访问性

MessageScroller 管理动态内容区域的滚动和焦点。新消息应有适当的 aria-live 区域通知。

API 参考

属性类型默认值说明
MessageScrollerProvider classHTMLAttributes["class"]undefined合并到滚动器容器的调用方类。
MessageScrollerViewport classHTMLAttributes["class"]undefined合并到视口的调用方类。
MessageScrollerContent classHTMLAttributes["class"]undefined合并到内容容器的调用方类。
MessageScrollerItem classHTMLAttributes["class"]undefined合并到每条消息项的调用方类。
MessageScrollerButton direction'top' | 'bottom''bottom'滚动按钮的指向方向。
useMessageScroller()MessageScrollerScrollable获取滚动器实例进行编程控制。
useMessageScrollerScrollable()ComputedRef<boolean>检测内容是否可滚动。
useMessageScrollerVisibility()MessageScrollerVisibilityState监听元素的视口可见性。