Anime um avatar de chat a partir de eventos de streaming
Por equipe Charmotion · Atualizado em

Para animar um avatar de chat a partir de um LLM em streaming, mapeie o status do useChat do Vercel AI SDK para os estados do avatar: submitted para thinking, streaming para talking, ready para idle com uma reação success única, e error para uma reação error. Controle um número e os triggers do view model do Rive a partir de um único efeito e mantenha o canvas montado fora da lista de mensagens.
Principais pontos
- O status do
useChattem quatro valores:submitted,streaming,readyeerror. Isso basta para controlar todos os estados do avatar. - Use um número para os estados em loop e triggers para as reações únicas, e dispare success apenas na transição de streaming para ready.
- Trate a interrupção pelo usuário como um caso à parte, para que ela não toque a reação success.
- Mantenha thinking por um tempo mínimo curto e renderize o avatar uma única vez, fora da lista de mensagens, para evitar piscadas e reinícios.
Já tem um personagem ou logo? Veja seu personagem em telas reais de app: telas de splash, onboarding, carregamento e estado vazio. É grátis, roda no navegador e não envia nada.
Quais eventos de streaming devem acionar cada estado do avatar?
O hook useChat do Vercel AI SDK já acompanha todo o ciclo de vida da requisição em um único campo. Seu status é 'ready' (ocioso), 'submitted' (requisição enviada), 'streaming' (recebendo a resposta) ou 'error' (a requisição falhou), segundo a referência do useChat. Esses quatro valores se encaixam perfeitamente em um personagem com humores em loop e reações únicas.
| Status do useChat | O que está acontecendo | Avatar |
|---|---|---|
submitted | Requisição enviada, nenhum token ainda | Loop thinking (mood 3) |
streaming | Tokens chegando | Loop talking (mood 7) |
ready depois de streaming | Resposta concluída | Disparar success uma vez, depois idle (mood 0) |
ready depois de uma interrupção pelo usuário | Resposta interrompida | Idle, sem reação |
error | A requisição falhou | Disparar error, depois sad (mood 4) |
| Nenhuma atividade por alguns minutos (seu timer) | Sessão ociosa | Sleeping (mood 8) |
Os números de mood aqui seguem um arquivo de personagem com dez humores (idle 0, thinking 3, sad 4, talking 7, sleeping 8). Se o seu arquivo usa outros valores ou nomes, altere apenas o mapa de constantes no código abaixo.
Listening é o único estado sobre o qual o useChat não tem como informar, porque a digitação acontece antes de qualquer requisição. Se quiser usá-lo, defina-o a partir dos eventos de foco e de alteração do seu campo de entrada. Em um chat digitado, muitas equipes pulam esse estado e deixam o avatar em idle enquanto o usuário escreve.

Como mapear o status do useChat para um input numérico do Rive?
Com o Data Binding do Rive, defina autoBind: true no useRive e a instância padrão do view model fica disponível como rive.viewModelInstance. Os hooks useViewModelInstanceNumber e useViewModelInstanceTrigger então retornam as funções setValue e trigger (Data Binding do Rive para React). No App Router do Next.js, isso precisa ser um client component, então o arquivo começa com "use client".
"use client";
import { useEffect, useRef } from "react";
import {
useRive,
useViewModelInstanceNumber,
useViewModelInstanceTrigger,
} from "@rive-app/react-webgl2";
type ChatStatus = "submitted" | "streaming" | "ready" | "error";
const MOOD = { idle: 0, thinking: 3, sad: 4, talking: 7 } as const;
export function ChatAvatar({ status, stopped }: { status: ChatStatus; stopped: boolean }) {
const { rive, RiveComponent } = useRive({
src: "/character.riv",
stateMachine: "CharacterViewModelMachine",
autoplay: true,
autoBind: true,
});
const vmi = rive?.viewModelInstance;
const { setValue: setMood } = useViewModelInstanceNumber("mood", vmi);
const { trigger: success } = useViewModelInstanceTrigger("success", vmi);
const { trigger: error } = useViewModelInstanceTrigger("error", vmi);
const last = useRef<ChatStatus | null>(null);
useEffect(() => {
if (!vmi || last.current === status) return;
const was = last.current;
last.current = status;
if (status === "submitted") setMood(MOOD.thinking);
else if (status === "streaming") setMood(MOOD.talking);
else if (status === "error") {
error();
setMood(MOOD.sad);
} else {
if ((was === "streaming" || was === "submitted") && !stopped) success();
setMood(MOOD.idle);
}
}, [vmi, status, stopped, setMood, success, error]);
return (
<div aria-hidden="true" style={{ width: 96, height: 96 }}>
<RiveComponent />
</div>
);
}
É a ref last que torna isso seguro. O efeito pode rodar de novo quando as funções do hook mudam de identidade, mas só age quando o status realmente mudou, então um trigger nunca dispara duas vezes para a mesma transição.
Se você não estiver usando os hooks do React, o runtime web puro faz o mesmo de forma imperativa: rive.viewModelInstance.number("mood").value = 3 e rive.viewModelInstance.trigger("success").trigger() (Data Binding do Rive para web).
Como tocar uma animação de sucesso só uma vez quando a resposta termina?
Use um trigger, não um mood. Um trigger é uma reação única: toca sua animação uma vez e depois a state machine volta para o loop atual. Um mood é um loop contínuo que continua tocando até você mudá-lo. Se você modelasse success como mood, precisaria de um timer para sair dele, e esse timer entraria em conflito com a próxima mensagem.
Dispare-o em uma transição, não em um valor. status === "ready" também é verdadeiro antes da primeira mensagem e depois da recuperação de um erro, então verificar só o valor faria o avatar comemorar ao carregar a página. A ref do status anterior no componente acima limita o trigger às respostas que realmente passaram por streaming.
A ordem importa menos do que você imagina. No código, success() é disparado e o humor volta para idle no mesmo tick. Se a reação única sobrevive a isso depende de como o arquivo foi conectado. Em um arquivo do Charmotion, uma mudança de humor nunca corta uma reação única: a reação termina e depois o personagem passa para o novo humor. O Charmotion gera esse arquivo a partir do seu próprio personagem ou logo, com dez humores e seis triggers (success, error, yes, no, enter, exit) em um view model Character, como parte de um conjunto de 17 animações; o .riv está incluído em todo plano pago. Se você criar o seu próprio arquivo, confira esse comportamento no editor do Rive antes de confiar nele.
Reserve o success para respostas que realmente fizeram algo, se for possível identificar. Uma reação depois de cada resposta pode ficar cansativa em uma conversa longa. Um meio-termo comum é dispará-la só quando uma chamada de ferramenta foi concluída, como uma nota salva ou uma reserva, e deixar as respostas comuns simplesmente voltarem para idle.
O que o avatar deve fazer em caso de erro ou quando o usuário aperta parar?
O useChat expõe stop() para abortar a resposta em streaming atual, além dos callbacks onError e onFinish (referência do useChat). Um stream abortado termina em ready, exatamente como um concluído, então o avatar não consegue diferenciar os dois só pelo status. Registre a interrupção você mesmo:
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { ChatAvatar } from "./ChatAvatar";
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
const [stopped, setStopped] = useState(false);
const send = (text: string) => {
setStopped(false);
sendMessage({ text });
};
const handleStop = () => {
setStopped(true);
stop();
};
return (
<aside>
<ChatAvatar status={status} stopped={stopped} />
{/* message list, input and a stop button that calls handleStop */}
</aside>
);
}
Em caso de erro, dispare o trigger error e mantenha um humor sad ou neutro até o usuário tentar de novo. Deixe a reação curta e nunca use efeitos piscantes; a mensagem de erro em si deve aparecer em texto ao lado do campo de entrada. Quando a próxima requisição for enviada, submitted leva o avatar de volta para thinking automaticamente.
Parar não é uma falha. Volte para idle sem reação, ou use uma pequena confirmação, como um trigger no ou yes, se o seu personagem tiver um e ele combinar com o seu tom.
Como evitar que o avatar pisque entre thinking e talking em respostas rápidas?
Modelos rápidos podem ir de submitted para streaming em bem menos de um segundo. Se o loop thinking recebe só alguns frames, parece que o avatar deu um bug. A solução é um tempo mínimo de permanência: quando thinking começar, mantenha esse estado por um curto período antes de liberar o próximo.
import { useEffect, useRef, useState } from "react";
type ChatStatus = "submitted" | "streaming" | "ready" | "error";
/** Delays leaving "submitted" until it has been shown for at least `ms`. */
export function useMinDwell(status: ChatStatus, ms = 400): ChatStatus {
const [shown, setShown] = useState(status);
const since = useRef(Date.now());
useEffect(() => {
if (status === shown) return;
const elapsed = Date.now() - since.current;
const wait = shown === "submitted" ? Math.max(0, ms - elapsed) : 0;
const t = setTimeout(() => {
since.current = Date.now();
setShown(status);
}, wait);
return () => clearTimeout(t);
}, [status, shown, ms]);
return shown;
}
// <ChatAvatar status={useMinDwell(status)} stopped={stopped} />
400 ms é um ponto de partida, não um padrão; ajuste com base no tempo real até o primeiro token do seu modelo. Aplique o atraso só ao avatar. A lista de mensagens deve continuar renderizando os tokens assim que eles chegam.
Como uma resposta muito rápida pode pular direto de submitted para ready depois do tempo mínimo, o componente de avatar acima dispara success a partir de qualquer um dos dois estados anteriores. Sem isso, respostas rápidas nunca receberiam sua reação.
Como evitar que o canvas seja remontado a cada mensagem?
A documentação de React do Rive alerta que, se o React desmontar ou recriar o canvas repetidamente, a animação pode reiniciar, sumir ou não ser exibida (boas práticas do Rive para React). Interfaces de chat causam exatamente isso sem querer, porque renderizam de novo a cada token recebido no streaming.
- Renderize um único avatar, fora da lista de mensagens. Coloque-o no cabeçalho ou ao lado do campo de entrada, não dentro de cada balão do assistente. Se quiser um avatar ao lado de cada mensagem, use o avatar Rive ao vivo só na resposta mais recente e uma imagem estática nas anteriores.
- Mantenha
useRiveeRiveComponentno mesmo componente pequeno, como emChatAvatar. Não passeRiveComponentpara cima ou para baixo na árvore. - Dê a ele uma posição estável e nenhuma
keyque mude. Uma key derivada do id da mensagem remonta o canvas a cada turno. - Passe só valores primitivos como props (o status, um booleano) e envolva o componente em
React.memose o componente pai renderizar com frequência.
Uma única instância também ajuda na performance. Veja animação de avatar de IA acessível e rápida para pausar o avatar fora da tela e lidar com movimento reduzido, e Rive em React e Next.js para a configuração do pacote.
Controle um avatar de chat Rive com useChat
- Carregue o arquivo com Data Binding. Chame
useRivecomautoBind: truee o nome da state machine, dentro de um componente"use client". - Obtenha as propriedades. Use
useViewModelInstanceNumber("mood", vmi)euseViewModelInstanceTriggerparasuccesseerror. - Mapeie o status para humores. Em um único effect, defina thinking em
submitted, talking emstreaming, idle emreadye sad emerror. - Dispare reações nas transições. Acompanhe o status anterior em um ref e dispare
successsó quando uma resposta terminar sem interrupção do usuário. - Suavize respostas rápidas. Mantenha thinking por um tempo mínimo curto antes de mostrar o próximo estado.
- Monte uma vez só. Renderize o avatar fora da lista de mensagens, com posição estável e sem uma key que mude.
Perguntas frequentes
Quais são os valores de status do useChat?
O status do useChat é 'submitted' quando a requisição é enviada, 'streaming' enquanto a resposta chega, 'ready' quando está ocioso e 'error' quando a requisição falha. Mapeie submitted para um loop thinking, streaming para talking, ready para idle com uma reação success opcional e error para uma reação de erro.
O avatar deve mudar para talking no primeiro token?
Sim. O useChat muda para 'streaming' assim que a resposta começa a chegar, que é o momento certo de trocar thinking por talking. Esperar a resposta completa faz o assistente parecer mais lento. Mantenha thinking por um tempo mínimo curto para que respostas muito rápidas não pisquem.
Por que minha animação success toca quando a página carrega?
Porque o status também é 'ready' antes da primeira mensagem. Dispare o trigger na transição de 'streaming' ou 'submitted' para 'ready', acompanhada com um ref, e não sempre que o status for igual a 'ready'. Também pule o disparo quando o usuário apertar parar, já que um stream abortado também termina em 'ready'.
Isso funciona com o runtime web simples do Rive?
Sim. Sem os hooks do React, defina rive.viewModelInstance.number("mood").value e chame rive.viewModelInstance.trigger("success").trigger() a partir do seu próprio listener de status. O mapeamento, a verificação do status anterior e o tratamento da interrupção continuam exatamente iguais; só muda a forma de acessar o view model, dos hooks para o acesso direto às propriedades da instância.
Respostas rápidas deste guia
Fontes
- useChat reference, AI SDK by Vercel
- React runtime: Data binding, Rive
- Web runtime: Data binding, Rive
- React runtime: Best practices, Rive