Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

Compositor de mensajes para IA

Compositor con sugerencias, límite de caracteres, atajo de envío y estados asíncronos; conserva el mensaje ante errores.

0/2000

Ctrl/⌘ + Enter para enviar · Enter para nueva línea

Código completo

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

ai-composer.tsx159 líneas
Descargar
"use client";

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

/** Compositor de mensajes para IA. React + TypeScript, sin dependencias. Personaliza con variables --gczm-*. */
export type AiComposerProps = {
  placeholder?: string;
  suggestions?: string[];
  maxLength?: number;
  onSubmit?: (prompt: string) => Promise<void> | void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gczm-root { box-sizing: border-box; min-width: 0; max-width: 100%; color: var(--gczm-fg, #202421); font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.gczm-root *, .gczm-root *::before, .gczm-root *::after { box-sizing: border-box; }
.gczm-root button, .gczm-root input, .gczm-root textarea, .gczm-root select { font: inherit; }
.gczm-root button, .gczm-root a { -webkit-tap-highlight-color: transparent; }
.gczm-root :focus-visible { outline: 2px solid var(--gczm-accent, #25724b); outline-offset: 4px; }
.gczm-root ::selection { background: var(--gczm-accent, #25724b); color: #fff; }
.gczm-root button:disabled { opacity: .5; cursor: not-allowed; }
.gczm-root svg { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .gczm-root *, .gczm-root *::before, .gczm-root *::after { animation: none !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gczm-root { width:560px; }
.gczm-root label { display:block; margin-bottom:14px; font-weight:600; font-size:16px; }
.gczm-box { background:var(--gczm-bg,#fff); border:1px solid #c5ccd5; border-radius:14px; padding:16px; }
.gczm-box:focus-within { border-color:#54749a; }
.gczm-root textarea { width:100%; min-height:100px; border:0; border-radius:4px; padding:4px; resize:vertical; background:transparent; color:#29374a; font-size:15px; outline-offset:1px; }
.gczm-root textarea::placeholder { color:#647388; }
.gczm-bottom { display:flex; justify-content:space-between; align-items:center; gap:20px; margin-top:10px; }
.gczm-bottom>span { font-size:11px; color:#647388; font-variant-numeric:tabular-nums; }
.gczm-bottom button { display:flex; align-items:center; gap:14px; min-height:44px; border:0; border-radius:7px; padding:10px 16px; background:var(--gczm-accent,#355b83); color:#fff; cursor:pointer; }
.gczm-bottom button:hover:not(:disabled) { background:#254669; }
.gczm-suggestions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
.gczm-suggestions button { min-height:44px; border:1px solid #c5ccd5; background:transparent; padding:8px 12px; border-radius:24px; font-size:11px; color:#4a5d74; cursor:pointer; }
.gczm-suggestions button:hover { background:#e2e9f0; }
.gczm-root>p { font-size:11px; color:#53667e; margin:16px 0 0; }`;

export function AiComposer({
  placeholder = "¿Qué quieres crear?",
  suggestions = [
    "Resumir un documento",
    "Preparar un plan",
    "Explorar una idea",
  ],
  maxLength = 2000,
  onSubmit,
  className = "",
  style,
}: AiComposerProps) {
  const [value, setValue] = useState("");
  const [state, setState] = useState<"idle" | "busy" | "sent" | "error">(
    "idle",
  );
  const mounted = useRef(false);
  const busy = useRef(false);
  const id = useId();
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);
  async function submit(event: FormEvent) {
    event.preventDefault();
    if (!value.trim() || busy.current) return;
    busy.current = true;
    setState("busy");
    try {
      await onSubmit?.(value.trim());
      if (mounted.current) setState("sent");
    } catch {
      if (mounted.current) setState("error");
    } finally {
      busy.current = false;
    }
  }
  return (
    <form className={`gczm-root ${className}`} style={style} onSubmit={submit}>
      <style>{CSS}</style>
      <label htmlFor={id}>Mensaje al asistente</label>
      <div className="gczm-box">
        <textarea
          id={id}
          value={value}
          maxLength={maxLength}
          disabled={state === "busy"}
          placeholder={placeholder}
          onChange={(e) => {
            setValue(e.target.value);
            setState("idle");
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
              e.preventDefault();
              e.currentTarget.form?.requestSubmit();
            }
          }}
        />
        <div className="gczm-bottom">
          <span>
            {value.length}/{maxLength}
          </span>
          <button
            type="submit"
            disabled={!value.trim() || state === "busy"}
            aria-busy={state === "busy"}
          >
            {state === "busy" ? "Enviando…" : "Enviar"}
            <svg
              width="16"
              height="16"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              aria-hidden="true"
            >
              <path d="M12 19V5m-6 6 6-6 6 6" />
            </svg>
          </button>
        </div>
      </div>
      <div className="gczm-suggestions">
        {suggestions.map((s) => (
          <button
            type="button"
            key={s}
            disabled={state === "busy"}
            onClick={() => {
              setValue(s.slice(0, maxLength));
              setState("idle");
            }}
          >
            {s}
          </button>
        ))}
      </div>
      <p role="status">
        {state === "sent"
          ? onSubmit
            ? "Mensaje enviado."
            : "Mensaje preparado en la demo. Conecta onSubmit a tu asistente."
          : state === "error"
            ? "No se pudo enviar. Conservamos tu mensaje para reintentar."
            : "Ctrl/⌘ + Enter para enviar · Enter para nueva línea"}
      </p>
    </form>
  );
}

Ú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-composer

Ejemplo de uso de Compositor de mensajes para IA

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

ejemplo.tsx5 líneas
import { AiComposer } from "./ai-composer";

export default function Ejemplo() {
  return <AiComposer placeholder="Ejemplo" />;
}

Propiedades de AiComposer

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

PropiedadTipoDescripción
placeholder?stringOpcional.
suggestions?string[]Opcional.
maxLength?numberOpcional.
onSubmit?(prompt: string) => Promise<void> | voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Compositor de mensajes para IA 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
--gczm-fg#202421
--gczm-accent#25724b
--gczm-bg#fff

Accesibilidad y rendimiento

El archivo tiene 159 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Compositor de mensajes para IA 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: status.
  • Responde al teclado, no solo al ratón.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)