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.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
/**
* 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.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-streamingEjemplo de uso de Respuesta en streaming
Después de copiar ai-streaming.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
text? | string | Opcional. |
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? | number | Milisegundos entre palabra y palabra. |
onFollowUp? | (question: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor 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".
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