Pular para o conteúdo
charmotion

Anime um avatar de chat a partir de eventos de streaming

Por equipe Charmotion · Atualizado em

Ilustração de capa do guia de avatares para assistentes de IA: Anime um avatar de chat a partir de eventos de streaming, com personagens de demonstração do Charmotion

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 useChat tem quatro valores: submitted, streaming, ready e error. 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 useChatO que está acontecendoAvatar
submittedRequisição enviada, nenhum token aindaLoop thinking (mood 3)
streamingTokens chegandoLoop talking (mood 7)
ready depois de streamingResposta concluídaDisparar success uma vez, depois idle (mood 0)
ready depois de uma interrupção pelo usuárioResposta interrompidaIdle, sem reação
errorA requisição falhouDisparar error, depois sad (mood 4)
Nenhuma atividade por alguns minutos (seu timer)Sessão ociosaSleeping (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.

Telas de assistente de chat com um avatar animado ouvindo, pensando, falando e confirmando
Estados do avatar do assistente mapeados para a conversa.

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.

talking: mood 7, enquanto o assistente responde.

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 useRive e RiveComponent no mesmo componente pequeno, como em ChatAvatar. Não passe RiveComponent para cima ou para baixo na árvore.
  • Dê a ele uma posição estável e nenhuma key que 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.memo se 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

  1. Carregue o arquivo com Data Binding. Chame useRive com autoBind: true e o nome da state machine, dentro de um componente "use client".
  2. Obtenha as propriedades. Use useViewModelInstanceNumber("mood", vmi) e useViewModelInstanceTrigger para success e error.
  3. Mapeie o status para humores. Em um único effect, defina thinking em submitted, talking em streaming, idle em ready e sad em error.
  4. Dispare reações nas transições. Acompanhe o status anterior em um ref e dispare success só quando uma resposta terminar sem interrupção do usuário.
  5. Suavize respostas rápidas. Mantenha thinking por um tempo mínimo curto antes de mostrar o próximo estado.
  6. 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

Guias relacionados