AI Elements

Message

One turn. The person's message, and the agent's reply with its actions.

How do I add a settings dialog? It should open from the gear icon in the header.

Install Dialog, then open it from the gear button in SiteHeader.vue.

bash
npx ui-thing@latest add dialog

Wrap the button in a DialogTrigger and put the settings form in DialogContent. Focus moves into the dialog when it opens, and Esc closes it.

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-pressed so 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>