Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

Respuesta en streaming

Respuesta que llega palabra por palabra, con cita numerada, fuentes, acciones y preguntas relacionadas.

Esta semana hay tres pedidos con retraso: #4821, #4833 y #4840. El más antiguo lleva seis días sin surtirse por falta de existencia en bodega.

Demostración interactiva

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

ai-streaming.tsx193 líneas
Descargar
/**
 * Respuesta de un asistente que llega palabra por palabra, con una cita numerada dentro
 * del texto, la lista de fuentes, acciones (copiar, regenerar, valorar) y preguntas
 * relacionadas. Lector de pantalla: recibe el texto completo desde el inicio, no la
 * animación. Los colores se sobrescriben con --gcai-ink, --gcai-surface, --gcai-line,
 * --gcai-muted y --gcai-accent.
 *
 *   <AiStreaming
 *     text="Hay tres pedidos con retraso. El más antiguo lleva seis días."
 *     cite={{ after: "retraso.", source: 1 }}
 *     sources={[{ label: "Pedidos de la semana", href: "/pedidos" }]}
 *     followUps={["¿Cuáles esperan material de bodega?"]}
 *     onFollowUp={(question) => ask(question)}
 *   />
 */

"use client";

import { useEffect, useState, type CSSProperties } from "react";

export type AiStreamingSource = { label: string; href?: string };
export type AiStreamingProps = {
  text?: string;
  /** Pone la cita `source` (desde 1) justo después de la frase `after`. */
  cite?: { after: string; source: number };
  sources?: AiStreamingSource[];
  followUps?: string[];
  /** Milisegundos entre palabra y palabra. */
  speed?: number;
  onFollowUp?: (question: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcst-root { --gcst-ink: var(--gcai-ink, #ededee); --gcst-muted: var(--gcai-muted, #9b9ca3); --gcst-line: var(--gcai-line, #26272c); --gcst-surface: var(--gcai-surface, #111113); --gcst-accent: var(--gcai-accent, #84b3ff); display: grid; gap: 20px; box-sizing: border-box; width: min(100%, 720px); margin-inline: auto; padding: clamp(20px, 4vw, 32px); border: 1px solid var(--gcst-line); border-radius: 16px; background: var(--gcst-surface); color: var(--gcst-ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.gcst-para { margin: 0; max-width: 62ch; font: 16px/1.7 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; text-wrap: pretty; }
.gcst-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.gcst-caret { display: inline-block; width: 2px; height: 1.05em; margin-left: 2px; background: var(--gcst-accent); vertical-align: -.16em; }
.gcst-cite { margin-left: 2px; padding: 0 2px; color: var(--gcst-accent); font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; text-decoration: none; vertical-align: super; }
a.gcst-cite:hover { text-decoration: underline; text-underline-offset: 2px; }
.gcst-cite:focus-visible { outline: 2px solid var(--gcst-accent); outline-offset: 2px; border-radius: 2px; }
.gcst-bar { display: flex; align-items: center; gap: 2px; margin-inline: -8px; }
.gcst-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--gcst-muted); cursor: pointer; transition: color .2s, background-color .2s; }
.gcst-btn:hover { color: var(--gcst-ink); background: color-mix(in srgb, var(--gcst-ink) 7%, transparent); }
.gcst-btn[aria-pressed="true"] { color: var(--gcst-accent); }
.gcst-btn:focus-visible { outline: 2px solid var(--gcst-accent); outline-offset: 1px; }
.gcst-btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.gcst-flash { margin-left: 6px; color: var(--gcst-muted); font-size: 12.5px; }
.gcst-sources { display: grid; gap: 6px; margin: 0; padding: 14px 0 0; border-top: 1px solid var(--gcst-line); list-style: none; }
.gcst-sources li { display: flex; align-items: baseline; gap: 10px; font-size: 13px; }
.gcst-sources b { min-width: 1ch; color: var(--gcst-accent); font-variant-numeric: tabular-nums; }
.gcst-sources a, .gcst-sources span { color: var(--gcst-ink); }
.gcst-sources a { text-decoration-color: var(--gcst-line); text-underline-offset: 3px; }
.gcst-sources a:hover { text-decoration-color: currentColor; }
.gcst-follow { display: grid; margin: 0; padding: 0; list-style: none; }
.gcst-follow button { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--gcst-line); background: transparent; color: var(--gcst-ink); font: inherit; font-size: 14px; text-align: left; cursor: pointer; transition: color .2s; }
.gcst-follow li:last-child button { border-bottom: 1px solid var(--gcst-line); }
.gcst-follow button:hover { color: var(--gcst-accent); }
.gcst-follow button:focus-visible { outline: 2px solid var(--gcst-accent); outline-offset: -2px; }
.gcst-follow svg { flex: none; width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.gcst-follow button:hover svg { transform: translateX(3px); }
.gcst-label { margin: 0 0 6px; color: var(--gcst-muted); font-size: 13px; }
@keyframes gcst-blink { 50% { opacity: 0; } }
.gcst-caret { animation: gcst-blink 1.1s steps(1, end) infinite; }
@media (prefers-reduced-motion: reduce) { .gcst-caret { animation: none; } .gcst-follow svg { transition: none; } }
`;

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(media.matches);
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);
  return reduced;
}

const ICONS = {
  copy: <svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.6" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>,
  redo: <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8A5.5 5.5 0 1 1 11.9 4.1" /><path d="M13.6 1.8v2.8H10.8" /></svg>,
  up: <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 7.2V14H2.6V7.2zM5 7.2l2.6-5.4a1.4 1.4 0 0 1 2.5 1.2L9.6 5.6h3.2a1.3 1.3 0 0 1 1.3 1.6l-1 5.4a1.3 1.3 0 0 1-1.3 1.1H5" /></svg>,
  down: <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 8.8V2H2.6v6.8zM5 8.8l2.6 5.4a1.4 1.4 0 0 0 2.5-1.2l-.5-2.6h3.2a1.3 1.3 0 0 0 1.3-1.6l-1-5.4A1.3 1.3 0 0 0 11.8 2H5" /></svg>,
  arrow: <svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7h10M8 3l4 4-4 4" /></svg>,
};

export function AiStreaming({
  text = "Esta semana hay tres pedidos con retraso: #4821, #4833 y #4840. El más antiguo lleva seis días sin surtirse por falta de existencia en bodega.",
  cite = { after: "#4840.", source: 1 },
  sources = [{ label: "Pedidos de la semana" }, { label: "Existencias en bodega" }],
  followUps = ["¿Cuáles esperan material de bodega?", "Muéstrame el retraso por cliente"],
  speed = 55,
  onFollowUp,
  className,
  style,
}: AiStreamingProps) {
  const reduced = useReducedMotion();
  const [run, setRun] = useState(0);
  const key = `${run}|${text}`;
  const [progress, setProgress] = useState({ key: "", shown: 0 });
  const shown = reduced ? text.length : progress.key === key ? progress.shown : 0;
  const [flash, setFlash] = useState("");
  const [rating, setRating] = useState<"up" | "down" | null>(null);

  useEffect(() => {
    if (reduced) return;
    const tokens = text.match(/\S+\s*/g) ?? [text];
    let index = 0;
    let position = 0;
    let timer: ReturnType<typeof setTimeout>;
    const step = () => {
      if (index >= tokens.length) return;
      position += tokens[index].length;
      index += 1;
      setProgress({ key, shown: position });
      timer = setTimeout(step, speed * (0.55 + ((index * 7) % 5) * 0.22));
    };
    timer = setTimeout(step, 280);
    return () => clearTimeout(timer);
  }, [text, speed, reduced, key]);

  const say = (message: string) => {
    setFlash(message);
    window.setTimeout(() => setFlash(""), 1800);
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      say("Copiado");
    } catch {
      say("No se pudo copiar");
    }
  };

  const citeIndex = text.indexOf(cite.after);
  const citeAt = citeIndex >= 0 ? citeIndex + cite.after.length : -1;
  const visible = text.slice(0, shown);
  const done = shown >= text.length;
  const source = sources[cite.source - 1];
  const marker = source && citeAt >= 0 && shown >= citeAt
    ? (source.href
        ? <a className="gcst-cite" href={source.href} target="_blank" rel="noreferrer" aria-label={`Fuente ${cite.source}: ${source.label}`}>{cite.source}</a>
        : <span className="gcst-cite" title={source.label}>{cite.source}</span>)
    : null;

  return (
    <>
      <style>{CSS}</style>
      <section className={["gcst-root", className].filter(Boolean).join(" ")} style={style} aria-label="Respuesta del asistente" aria-busy={!done}>
        <p className="gcst-sr">{text}</p>
        <p className="gcst-para" aria-hidden="true">
          {citeAt >= 0 ? <>{visible.slice(0, citeAt)}{marker}{visible.slice(citeAt)}</> : visible}
          {!done && <span className="gcst-caret" />}
        </p>
        {done && (
          <div className="gcst-bar" role="group" aria-label="Acciones de la respuesta">
            <button type="button" className="gcst-btn" title="Copiar" aria-label="Copiar respuesta" onClick={copy}>{ICONS.copy}</button>
            <button type="button" className="gcst-btn" title="Regenerar" aria-label="Regenerar respuesta" onClick={() => { setRating(null); setRun((n) => n + 1); }}>{ICONS.redo}</button>
            <button type="button" className="gcst-btn" title="Útil" aria-label="Respuesta útil" aria-pressed={rating === "up"} onClick={() => setRating(rating === "up" ? null : "up")}>{ICONS.up}</button>
            <button type="button" className="gcst-btn" title="No útil" aria-label="Respuesta no útil" aria-pressed={rating === "down"} onClick={() => setRating(rating === "down" ? null : "down")}>{ICONS.down}</button>
            <span className="gcst-flash" role="status">{flash}</span>
          </div>
        )}
        {done && sources.length > 0 && (
          <ol className="gcst-sources" aria-label="Fuentes">
            {sources.map((s, i) => (
              <li key={s.label}>
                <b>{i + 1}</b>
                {s.href ? <a href={s.href} target="_blank" rel="noreferrer">{s.label}</a> : <span>{s.label}</span>}
              </li>
            ))}
          </ol>
        )}
        {done && followUps.length > 0 && (
          <div>
            <p className="gcst-label">Preguntas relacionadas</p>
            <ul className="gcst-follow">
              {followUps.map((q) => (
                <li key={q}>
                  <button type="button" onClick={() => onFollowUp?.(q)}>{q}{ICONS.arrow}</button>
                </li>
              ))}
            </ul>
          </div>
        )}
      </section>
    </>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-streaming

Ejemplo de uso de Respuesta en streaming

Después de copiar ai-streaming.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { AiStreaming } from "./ai-streaming";

export default function Ejemplo() {
  return <AiStreaming text="Ejemplo" />;
}

Propiedades de AiStreaming

AiStreaming acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
text?stringOpcional.
cite?{ after: string; source: number }Pone la cita `source` (desde 1) justo después de la frase `after`.
sources?AiStreamingSource[]Opcional.
followUps?string[]Opcional.
speed?numberMilisegundos entre palabra y palabra.
onFollowUp?(question: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Respuesta en streaming con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--gcai-ink#ededee
--gcai-muted#9b9ca3
--gcai-line#26272c
--gcai-surface#111113
--gcai-accent#84b3ff

Accesibilidad y rendimiento

El archivo tiene 193 líneas (11 KB) y define 1 animación con @keyframes. Esto es lo que hace Respuesta en streaming por quien lo usa:

  • Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: group, status.
  • Calcula tonos con color-mix(), así que un solo color base alcanza.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18

Acerca del producto

El texto aparece con ritmo irregular, como llega de un modelo. La cita se inserta dentro de la frase y las fuentes, acciones y preguntas aparecen al terminar. El lector de pantalla recibe el texto completo desde el inicio.

Incluye

Un solo archivo React + TypeScript con estilos dentro. Sin Tailwind ni dependencias externas.

  • Ritmo por palabras con variación
  • Cita numerada dentro del texto
  • Lista de fuentes con enlaces opcionales
  • Copiar, regenerar y valorar
  • Preguntas relacionadas con callback