AI Elements
Message
One turn. The person's message, and the agent's reply with its actions.
Use Message for one turn in a thread. The person's text is a Bubble on a Message with align="end". The agent's reply is markdown on a Message, with actions under it. A thread of these turns is Chat.
Installation
Message is built with the Message, Bubble, and Tooltip components.
Usage
<template>
<UiMessage align="end">
<UiMessageContent>
<UiBubble align="end" variant="muted">
<UiBubbleContent>How do I add a settings dialog?</UiBubbleContent>
</UiBubble>
</UiMessageContent>
</UiMessage>
<UiMessage>
<UiMessageContent class="gap-2">
<p>Install Dialog, then open it from the gear button.</p>
<UiMessageFooter>
<UiButton variant="ghost" size="icon-sm" aria-label="Copy">
<UiIcon name="lucide:copy" />
</UiButton>
</UiMessageFooter>
</UiMessageContent>
</UiMessage>
</template>
See Message.
Actions
Put actions in UiMessageFooter. It follows the message alignment, so on the person's turn the actions sit on the right. Use ghost icon buttons with a tooltip for each.
- Copy writes the reply to the clipboard and swaps to a check for two seconds.
- Good response and Bad response are toggles. Set
aria-pressedso screen readers announce the state. - Retry runs the turn again.
- Edit on the person's turn puts the text back in the field.
Markdown
The reply streams as markdown. Render it with Comark (@comark/vue) and route fenced code to a Code Block with a custom pre component, so code in the reply gets line numbers and a copy button. Pass streaming while the reply streams, so unfinished syntax is closed as it renders.
<template>
<Markdown
class="typeset"
:value="text"
:components="{ pre: MarkdownCodeBlock }"
:streaming="isStreaming"
/>
</template>
<script lang="ts" setup>
import { Markdown } from "@comark/vue";
import MarkdownCodeBlock from "./MarkdownCodeBlock.vue";
</script>
AI SDK
Branch on the message role. The person's turn is the bubble. The agent's turn is the reply, and chat.regenerate with the message id runs it again.
<template>
<template v-for="message in chat.messages" :key="message.id">
<UiMessage v-if="message.role === 'user'" align="end">
<UiMessageContent>
<UiBubble align="end" variant="muted">
<UiBubbleContent>{{ getText(message) }}</UiBubbleContent>
</UiBubble>
</UiMessageContent>
</UiMessage>
<UiMessage v-else>
<UiMessageContent class="gap-2">
<Markdown
class="typeset"
:value="getText(message)"
:components="{ pre: MarkdownCodeBlock }"
:streaming="chat.status === 'streaming' && message === chat.messages.at(-1)"
/>
<UiMessageFooter>
<UiButton
variant="ghost"
size="icon-sm"
aria-label="Retry"
@click="chat.regenerate({ messageId: message.id })"
>
<UiIcon name="lucide:refresh-ccw" />
</UiButton>
</UiMessageFooter>
</UiMessageContent>
</UiMessage>
</template>
</template>
<script lang="ts" setup>
import { Chat } from "@ai-sdk/vue";
import { Markdown } from "@comark/vue";
import type { UIMessage } from "ai";
import MarkdownCodeBlock from "./MarkdownCodeBlock.vue";
const chat = new Chat({});
function getText(message: UIMessage) {
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
}
</script>