Vitral 0.2
Mensagens

Chat

Uma conversa sem dependências, em cinco formatos, de uma thread simples a um agente com suas chamadas de ferramenta, com respostas que chegam no lugar.

Importação

main.ts
import { Chat } from '@vitral/vue';

Padrão

Pergunte alguma coisa. A resposta chega um token por vez, com um cursor depois do último caractere, e é anunciada quando se assenta. Anexe um arquivo com o clipe; Enter envia e Shift+Enter quebra a linha.

Today
Can I theme this?
Vitral
Yes — every colour is a token. Ask me anything else.
<script setup lang="ts">import { Chat, type ChatAttachment, type ChatMessage, type ChatSendPayload } from '@vitral/vue';import { onBeforeUnmount, ref } from 'vue'; let nextId = 100;const answers = [    'Every colour a component draws comes from a token, so a preset changes all of them at once. `dt` overrides them for one instance.',    'The addon has no framework in it: `createChat(element, config)` draws into any element and returns a handle. The Vue component is a wrapper around that.',    'The log is one tab stop. The arrows move a message, Home and End jump, and the composer is always one Tab away.'];let answer = 0; const messages = ref<ChatMessage[]>([    { id: 1, role: 'system', content: 'Today' },    { id: 2, role: 'user', content: 'Can I theme this?', at: new Date(2026, 8, 20, 9, 12) },    {        id: 3,        role: 'assistant',        author: 'Vitral',        content: 'Yes — every colour is a token. Ask me anything else.',        at: new Date(2026, 8, 20, 9, 12),        citations: [{ title: 'Theming', url: '#' }]    }]);const draft = ref('');const typing = ref(false);const staged = ref<ChatAttachment[]>([]);let timer = 0; function reply() {    const text = answers[answer % answers.length]!;    answer++;    const id = nextId++;    typing.value = false;    messages.value = [...messages.value, { id, role: 'assistant', author: 'Vitral', content: '', streaming: true, at: new Date() }];    // A token at a time, which is what makes the caret worth drawing.    const words = text.split(' ');    let at = 0;    timer = window.setInterval(() => {        at++;        const done = at >= words.length;        messages.value = messages.value.map((m) => (m.id === id ? { ...m, content: words.slice(0, at).join(' '), streaming: !done } : m));        if (done) window.clearInterval(timer);    }, 55);} function send({ text, attachments }: ChatSendPayload) {    messages.value = [...messages.value, { id: nextId++, role: 'user', content: text, at: new Date(), attachments: attachments.length ? attachments : undefined }];    draft.value = '';    staged.value = [];    typing.value = true;    window.clearInterval(timer);    timer = window.setTimeout(reply, 600);} const attach = (files: File[]) => (staged.value = [...staged.value, ...files.map((f) => ({ name: f.name, size: f.size, type: f.type }))]);onBeforeUnmount(() => window.clearInterval(timer));</script> <template>    <Chat        v-model:draft="draft"        v-model:attachments="staged"        :messages="messages"        :typing="typing"        :suggestions="['Can I theme this?', 'Does it need a framework?', 'How does the keyboard work?']"        allow-attachments        aria-label="Vitral assistant"        height="22rem"        style="width: 100%"        @send="send"        @attach="attach"    /></template>

Duas pessoas

`variant="messenger"`: os dois lados ganham balão e avatar, e uma sequência de mensagens da mesma pessoa fica agrupada sob um só nome.

Are we still on for 3?
Priya
Yes. I pushed the deck.
Second slide needs your numbers.
On it.
<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue'; const chat: ChatMessage[] = [    { id: 1, role: 'user', content: 'Are we still on for 3?', at: new Date(2026, 8, 20, 14, 2) },    { id: 2, role: 'assistant', author: 'Priya', initials: 'PR', content: 'Yes. I pushed the deck.', at: new Date(2026, 8, 20, 14, 4) },    { id: 3, role: 'assistant', author: 'Priya', initials: 'PR', content: 'Second slide needs your numbers.', at: new Date(2026, 8, 20, 14, 4), attachments: [{ name: 'q3-deck.pdf', size: 2_400_000, url: '#' }] },    { id: 4, role: 'user', content: 'On it.', at: new Date(2026, 8, 20, 14, 6) }];</script> <template>    <Chat :messages="chat" variant="messenger" placeholder="Reply to Priya" aria-label="Priya" height="18rem" style="width: 100%" /></template>

Um agente e suas ferramentas

`variant="agent"`: cada ferramenta a que o assistente recorreu é um passo que pode ser aberto — o que lhe foi pedido e o que respondeu —, e um que ainda está rodando avisa. Essa é toda a razão de mostrar os passos.

How many customers churned last month?
Analyst
run_queryconcluído
SELECT count(*) FROM churn WHERE month = 8

14
compare_periodconcluído
month = 7

22
Fourteen, down from twenty-two in July. The fall is almost all in the Starter plan.
draw_chartexecutando
churn by plan
<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue'; const agent: ChatMessage[] = [    { id: 1, role: 'user', content: 'How many customers churned last month?' },    {        id: 2,        role: 'assistant',        author: 'Analyst',        content: 'Fourteen, down from twenty-two in July. The fall is almost all in the Starter plan.',        toolCalls: [            { name: 'run_query', input: 'SELECT count(*) FROM churn WHERE month = 8', output: '14', status: 'done' },            { name: 'compare_period', input: 'month = 7', output: '22', status: 'done' }        ],        citations: [{ title: 'churn.sql', url: '#' }]    },    { id: 3, role: 'assistant', author: 'Analyst', content: '', toolCalls: [{ name: 'draw_chart', input: 'churn by plan', status: 'running' }] }];</script> <template>    <Chat :messages="agent" variant="agent" readonly aria-label="Analyst" height="20rem" style="width: 100%" /></template>

Um copilot ao lado do trabalho

`variant="copilot"`: uma coluna estreita, sem balões e sem avatares, para um painel que fica ao lado do que está sendo escrito.

Explain this function.
Copilot
It groups consecutive messages from one speaker so the avatar is drawn once a run rather than once a message.
What if two people share a role?
Copilot
Then the author breaks the run: the names differ, so the messages do not join.
<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue'; const copilot: ChatMessage[] = [    { id: 1, role: 'user', content: 'Explain this function.' },    { id: 2, role: 'assistant', content: 'It groups consecutive messages from one speaker so the avatar is drawn once a run rather than once a message.' },    { id: 3, role: 'user', content: 'What if two people share a role?' },    { id: 4, role: 'assistant', content: 'Then the author breaks the run: the names differ, so the messages do not join.' }];</script> <template>    <div style="max-width: 22rem; width: 100%">        <Chat :messages="copilot" variant="copilot" placeholder="Ask about this file" aria-label="Copilot" height="16rem" />    </div></template>

Legendas

`variant="captions"`: uma transcrição corrida em vez de uma conversa — sem balões, sem lados, o nome de quem fala na frente, e a linha ainda sendo reconhecida levando o cursor.

Ana
So the second quarter came in ahead of plan.
You
By how much?
Ana
About seven per cent, mostly expansion rather than new logos
<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue'; // Speech as it is recognised.const captions: ChatMessage[] = [    { id: 1, role: 'assistant', author: 'Ana', content: 'So the second quarter came in ahead of plan.' },    { id: 2, role: 'user', author: 'You', content: 'By how much?' },    { id: 3, role: 'assistant', author: 'Ana', content: 'About seven per cent, mostly expansion rather than new logos', streaming: true }];</script> <template>    <Chat :messages="captions" variant="captions" readonly aria-label="Transcript" height="8rem" style="width: 100%" /></template>

Um widget

`variant="widget"`: um launcher, e a thread num painel que abre sobre a página. O launcher informa se o painel está aberto e se nomeia em qualquer caso.

<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue';import { ref } from 'vue'; const chat: ChatMessage[] = [    { id: 1, role: 'user', content: 'Are we still on for 3?', at: new Date(2026, 8, 20, 14, 2) },    { id: 2, role: 'assistant', author: 'Priya', initials: 'PR', content: 'Yes. I pushed the deck.', at: new Date(2026, 8, 20, 14, 4) },    { id: 3, role: 'assistant', author: 'Priya', initials: 'PR', content: 'Second slide needs your numbers.', at: new Date(2026, 8, 20, 14, 4), attachments: [{ name: 'q3-deck.pdf', size: 2_400_000, url: '#' }] },    { id: 4, role: 'user', content: 'On it.', at: new Date(2026, 8, 20, 14, 6) }]; const widgetOpen = ref(false);</script> <template>    <div style="display: flex; justify-content: flex-end; width: 100%; min-height: 4rem">        <Chat v-model:open="widgetOpen" :messages="chat" variant="widget" aria-label="Support" height="18rem" />    </div></template>

Quando uma resposta falha

Uma mensagem com `error` ocupa o seu lugar na thread, e `retryable` coloca embaixo dela o botão de perguntar de novo.

Summarise the thread.
Failed answer
The model did not answer in time.
<script setup lang="ts">import { Chat, type ChatMessage } from '@vitral/vue';import { ref } from 'vue'; const failed: ChatMessage[] = [    { id: 1, role: 'user', content: 'Summarise the thread.' },    { id: 2, role: 'assistant', error: 'The model did not answer in time.', retryable: true }]; const retried = ref(0);</script> <template>    <Chat :messages="failed" readonly aria-label="Failed answer" height="10rem" style="width: 100%" @retry="retried++" />    <template v-if="retried"><small style="color: var(--vt-text-muted-color)">Asked again {{ retried }} times</small></template></template>

API

Lido de packages/vue/src/components/Chat/types.ts, então diz o que o componente aceita de fato.

Props

NomeTipoDescrição
messagesChatMessage[]A conversa. `v-model:messages` não é oferecido: o que é dito cabe à aplicação decidir.
variantChatVariant—
placeholderstring—
suggestionsstring[]Sugestões para começar, mostradas enquanto a conversa está vazia.
emptyMessagestring—
typingboolean | string`true` para os três pontos, ou as palavras a mostrar no lugar.
disabledboolean—
readonlybooleanSem campo de composição: uma transcrição.
allowAttachmentsboolean—
acceptstring—
maxRowsnumberLinhas até onde o campo de composição cresce antes de rolar. O padrão é 6.
sendOnEnterbooleanEnter envia e Shift+Enter quebra a linha. False inverte os dois.
heightstringAltura da área rolável da conversa. O padrão é `'28rem'`.

Mais pt, dt e unstyled de BaseProps; veja pass-through e modo sem estilo.

Slots

NomeProps do slotDescrição
header—Acima da conversa: um título, um seletor de modelo, um botão de fechar.
footer—Abaixo do campo de composição: um aviso, uma contagem de tokens.
message(props: { message: ChatMessage; index: number })Substitui todo o corpo de uma mensagem.
empty—Substitui o que é mostrado enquanto nada foi dito.
launcher(props: { open: boolean })O botão que abre o widget.
default—As partes escritas como filhos.