Anima un avatar de chat con eventos de streaming
Por el equipo de Charmotion · Actualizado el

Para animar un avatar de chat a partir de un LLM con streaming, asigna el estado de useChat del Vercel AI SDK a los estados del avatar: submitted a thinking, streaming a talking, ready a idle con una reacción de éxito que se reproduce una sola vez, y error a una reacción de error. Controla un número y los triggers de un view model de Rive desde un solo efecto, y mantén el canvas montado fuera de la lista de mensajes.
Puntos clave
- El estado de
useChattiene cuatro valores:submitted,streaming,readyyerror. Con eso basta para controlar todos los estados del avatar. - Usa un número para los estados en loop y triggers para las reacciones puntuales, y dispara success solo en la transición de streaming a ready.
- Trata la detención por parte del usuario como un caso aparte para que no reproduzca una reacción de éxito.
- Mantén thinking durante un tiempo mínimo breve y renderiza el avatar una sola vez, fuera de la lista de mensajes, para evitar parpadeos y reinicios.
¿Ya tienes un personaje o logo? Mira tu personaje en pantallas reales de una app: pantallas de splash, onboarding, carga y estado vacío. Es gratis, funciona en tu navegador y no sube nada.
¿Qué eventos de streaming deben controlar cada estado del avatar?
El hook useChat del Vercel AI SDK ya registra todo el ciclo de vida de la solicitud en un solo campo. Su status es 'ready' (en reposo), 'submitted' (solicitud enviada), 'streaming' (recibiendo la respuesta) o 'error' (la solicitud falló), según la referencia de useChat. Esos cuatro valores encajan sin problemas con un personaje que tiene estados de ánimo en loop y reacciones puntuales.
| Estado de useChat | Qué ocurre | Avatar |
|---|---|---|
submitted | Solicitud enviada, aún sin tokens | Loop thinking (mood 3) |
streaming | Llegan tokens | Loop talking (mood 7) |
ready después de streaming | Respuesta terminada | Dispara success una vez y luego idle (mood 0) |
ready después de que el usuario detiene la respuesta | Respuesta cancelada | Idle, sin reacción |
error | La solicitud falló | Dispara error y luego sad (mood 4) |
| Sin actividad durante unos minutos (tu temporizador) | Sesión inactiva | Sleeping (mood 8) |
Los números de mood aquí corresponden a un archivo de personaje con diez estados de ánimo (idle 0, thinking 3, sad 4, talking 7, sleeping 8). Si tu archivo usa otros valores o nombres, cambia solo el mapa de constantes del código de abajo.
Listening es el único estado del que useChat no puede informarte, porque la escritura ocurre antes de cualquier solicitud. Si lo quieres, actívalo con los eventos de foco y cambio de tu campo de entrada. En un chat de texto, muchos equipos lo omiten y dejan el avatar en reposo mientras el usuario escribe.

¿Cómo asigno el estado de useChat a un input numérico de Rive?
Con Data Binding de Rive, define autoBind: true en useRive y la instancia predeterminada del view model queda disponible como rive.viewModelInstance. Luego, los hooks useViewModelInstanceNumber y useViewModelInstanceTrigger devuelven las funciones setValue y trigger (Data Binding de Rive en React). En el App Router de Next.js debe ser un componente de cliente, así que el archivo empieza con "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>
);
}
La ref last es lo que hace que esto sea seguro. El efecto puede volver a ejecutarse cuando las funciones de los hooks cambian de identidad, pero solo actúa cuando el estado realmente cambió, así que un trigger nunca se dispara dos veces en una misma transición.
Si no usas los hooks de React, el runtime web básico hace lo mismo de forma imperativa: rive.viewModelInstance.number("mood").value = 3 y rive.viewModelInstance.trigger("success").trigger() (Data Binding de Rive en la web).
¿Cómo reproduzco una animación de éxito una sola vez cuando termina la respuesta?
Usa un trigger, no un estado de ánimo. Un trigger es una reacción puntual: reproduce su animación y luego la máquina de estados vuelve al loop que esté activo. Un estado de ánimo es un loop de reposo que sigue reproduciéndose hasta que lo cambias. Si modelaras success como estado de ánimo, necesitarías un temporizador para salir de él, y ese temporizador competiría con el siguiente mensaje.
Dispáralo en una transición, no según un valor. status === "ready" también es verdadero antes del primer mensaje y después de recuperarse de un error, así que comprobar solo el valor provocaría una celebración al cargar la página. La ref del estado anterior en el componente de arriba limita el trigger a las respuestas que realmente llegaron por streaming.
El orden importa menos de lo que podrías pensar. En el código, success() se dispara y luego el mood vuelve a idle en el mismo tick. Que la reacción puntual sobreviva depende de cómo esté conectado el archivo. En un archivo de Charmotion, un cambio de mood nunca interrumpe una reacción puntual: la reacción termina y luego el personaje se asienta en el nuevo mood. Charmotion genera ese archivo a partir de tu propio personaje o logo, con diez moods y seis triggers (success, error, yes, no, enter, exit) en un view model Character, como parte de un set de 17 animaciones; el .riv está incluido en todos los planes de pago. Si creas tu propio archivo, comprueba este comportamiento en el editor de Rive antes de depender de él.
Reserva success para las respuestas que realmente hicieron algo, si puedes distinguirlas. Una reacción después de cada respuesta puede resultar molesta en una conversación larga. Un término medio habitual es dispararla solo cuando se completó una llamada a una herramienta, como una nota guardada o una reserva, y dejar que las respuestas normales simplemente vuelvan a idle.
¿Qué debe hacer el avatar ante un error o cuando el usuario pulsa detener?
useChat expone stop() para abortar la respuesta en streaming actual, además de los callbacks onError y onFinish (referencia de useChat). Un stream abortado termina en ready, exactamente igual que uno completado, así que el avatar no puede distinguirlos solo con status. Registra tú mismo la detención:
"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>
);
}
Ante un error, dispara el trigger error y quédate en un estado de ánimo sad o neutral hasta que el usuario lo intente de nuevo. Mantén la reacción breve y nunca uses destellos; el mensaje de error en sí va como texto junto al campo de entrada. Cuando se envía la siguiente solicitud, submitted devuelve el avatar a thinking automáticamente.
Detener no es fallar. Vuelve a idle sin reacción, o usa una confirmación discreta, como un trigger no o yes, si tu personaje lo tiene y encaja con tu tono.
¿Cómo evito el parpadeo entre thinking y talking en respuestas rápidas?
Los modelos rápidos pueden pasar de submitted a streaming en bastante menos de un segundo. Si el loop de thinking dura solo unos pocos fotogramas, parece que el avatar tuvo un fallo. La solución es un tiempo mínimo de permanencia: cuando empieza thinking, mantenlo durante un breve período antes de dejar pasar el siguiente estado.
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 es un punto de partida, no un estándar; ajústalo según el tiempo real hasta el primer token de tu modelo. Aplica el retraso solo al avatar. La lista de mensajes debe seguir mostrando los tokens en cuanto llegan.
Como una respuesta muy rápida puede saltar directamente de submitted a ready después de la permanencia, el componente de avatar anterior dispara success desde cualquiera de los dos estados previos. Sin eso, las respuestas rápidas nunca tendrían su reacción.
¿Cómo evito que el canvas se vuelva a montar con cada mensaje?
La documentación de Rive para React advierte que, si React desmonta o recrea el canvas repetidamente, la animación puede reiniciarse, desaparecer o no mostrarse (buenas prácticas de Rive en React). Las interfaces de chat provocan justo eso sin querer, porque se vuelven a renderizar con cada token recibido en streaming.
- Renderiza un solo avatar, fuera de la lista de mensajes. Colócalo en el header o junto al campo de entrada, no dentro de cada burbuja del asistente. Si quieres un avatar junto a cada mensaje, usa el avatar de Rive en vivo solo para la última respuesta y una imagen estática para las anteriores.
- Mantén
useRiveyRiveComponenten el mismo componente pequeño, como enChatAvatar. No pasesRiveComponenthacia arriba ni hacia abajo en el árbol. - Dale una posición estable y ninguna
keycambiante. Una key derivada del id del mensaje vuelve a montar el canvas en cada turno. - Pasa solo valores primitivos como props (status, un booleano) y envuélvelo en
React.memosi el componente padre se renderiza a menudo.
Una sola instancia también mejora el rendimiento. Consulta animación de avatares de IA accesible y rápida para pausarla fuera de pantalla y gestionar el movimiento reducido, y Rive en React y Next.js para configurar el paquete.
Controla un avatar de chat de Rive desde useChat
- Carga el archivo con Data Binding. Llama a
useRiveconautoBind: truey el nombre de la máquina de estados, dentro de un componente"use client". - Obtén las propiedades. Usa
useViewModelInstanceNumber("mood", vmi)yuseViewModelInstanceTriggerparasuccessyerror. - Asigna el status a estados de ánimo. En un solo efecto, usa thinking con
submitted, talking constreaming, idle conreadyy sad conerror. - Dispara reacciones en las transiciones. Guarda el status anterior en un ref y dispara
successsolo cuando una respuesta terminó sin que el usuario la detuviera. - Suaviza las respuestas rápidas. Mantén thinking durante un breve tiempo mínimo antes de mostrar el siguiente estado.
- Monta una sola vez. Renderiza el avatar fuera de la lista de mensajes, con una posición estable y sin una key cambiante.
Preguntas frecuentes
¿Cuáles son los valores de status de useChat?
El status de useChat es 'submitted' cuando se envía la solicitud, 'streaming' mientras llega la respuesta, 'ready' cuando está inactivo y 'error' cuando la solicitud falló. Asigna submitted a un loop de thinking, streaming a talking, ready a idle con una reacción success opcional, y error a una reacción de error.
¿El avatar debe pasar a talking con el primer token?
Sí. useChat pasa a 'streaming' en cuanto empieza a llegar la respuesta, que es el momento adecuado para cambiar de thinking a talking. Esperar a la respuesta completa hace que el asistente parezca más lento. Mantén thinking durante un breve tiempo mínimo para que las respuestas muy rápidas no parpadeen.
¿Por qué mi animación de éxito se reproduce al cargar la página?
Porque el status también es 'ready' antes del primer mensaje. Dispara el trigger en la transición de 'streaming' o 'submitted' a 'ready', registrada con un ref, en lugar de hacerlo siempre que status sea 'ready'. Omítelo también cuando el usuario haya pulsado detener, ya que un stream abortado también termina en 'ready'.
¿Funciona con el runtime web de Rive sin React?
Sí. Sin los hooks de React, asigna rive.viewModelInstance.number("mood").value y llama a rive.viewModelInstance.trigger("success").trigger() desde tu propio listener de status. La asignación, la comprobación del status anterior y la gestión de la detención son exactamente iguales; solo cambia cómo accedes al view model: de hooks a acceso directo a las propiedades de la instancia.
Respuestas rápidas de esta guía
Fuentes
- useChat reference, AI SDK by Vercel
- React runtime: Data binding, Rive
- Web runtime: Data binding, Rive
- React runtime: Best practices, Rive