Llamada a herramienta
Fila de una herramienta ejecutada por un agente, con estado, duración, argumentos y resultado.
Argumentos
SELECT folio, cliente, dias_retraso FROM pedidos WHERE estado = 'retrasado' ORDER BY dias_retraso DESC LIMIT 10
Resultado
3 pedidos encontrados
Argumentos
UPDATE pedidos SET avisado = 1 WHERE folio = 4821
Argumentos
SELECT sku FROM existencias WHERE bodega = 'norte'
Error
Tiempo de espera agotado tras 30 s.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
/**
* Llamada de un agente a una herramienta. La fila siempre visible muestra estado, nombre y
* duración; al abrirla aparecen los argumentos (con la sintaxis SQL resaltada) y el
* resultado. Cuatro estados: pendiente, en curso, completada y error; un error se abre
* solo. Los colores se sobrescriben con --gcai-ink, --gcai-surface, --gcai-line,
* --gcai-muted y --gcai-accent.
*
* <AiToolCall
* tool="consultar_pedidos"
* state="success"
* args="SELECT folio FROM pedidos WHERE estado = 'retrasado'"
* result="3 pedidos encontrados"
* duration="0.12 s"
* />
*/
"use client";
import { useId, useState, type CSSProperties, type ReactNode } from "react";
export type AiToolCallState = "running" | "success" | "error" | "pending";
export type AiToolCallProps = {
tool?: string;
state?: AiToolCallState;
args?: string;
result?: string;
duration?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gctc-root { --gctc-ink: var(--gcai-ink, #ededee); --gctc-muted: var(--gcai-muted, #9b9ca3); --gctc-line: var(--gcai-line, #26272c); --gctc-surface: var(--gcai-surface, #111113); --gctc-accent: var(--gcai-accent, #84b3ff); box-sizing: border-box; width: min(100%, 640px); margin-inline: auto; border: 1px solid var(--gctc-line); border-radius: 12px; background: var(--gctc-surface); color: var(--gctc-ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.gctc-head { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.gctc-head:focus-visible { outline: 2px solid var(--gctc-accent); outline-offset: -2px; }
.gctc-glyph { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.gctc-root[data-state="success"] .gctc-glyph { color: #5fd38d; }
.gctc-root[data-state="error"] .gctc-glyph { color: #ff7a70; }
.gctc-root[data-state="pending"] .gctc-glyph { color: var(--gctc-muted); }
.gctc-root[data-state="running"] .gctc-glyph { color: var(--gctc-accent); animation: gctc-spin .8s linear infinite; }
.gctc-name { font: 500 13px/1 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.gctc-word { color: var(--gctc-muted); font-size: 13px; }
.gctc-root[data-state="error"] .gctc-word { color: #ff7a70; }
.gctc-time { margin-left: auto; color: var(--gctc-muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.gctc-chevron { flex: none; width: 14px; height: 14px; fill: none; stroke: var(--gctc-muted); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; transition: transform .3s cubic-bezier(.16, 1, .3, 1); }
.gctc-head[aria-expanded="true"] .gctc-chevron { transform: rotate(180deg); }
.gctc-panel { display: grid; grid-template-rows: 0fr; visibility: hidden; transition: grid-template-rows .4s cubic-bezier(.16, 1, .3, 1), visibility 0s .4s; }
.gctc-panel[data-open="true"] { grid-template-rows: 1fr; visibility: visible; transition: grid-template-rows .4s cubic-bezier(.16, 1, .3, 1), visibility 0s; }
.gctc-clip { min-height: 0; overflow: hidden; }
.gctc-body { display: grid; gap: 14px; padding: 14px; border-top: 1px solid var(--gctc-line); }
.gctc-body h4 { margin: 0 0 6px; color: var(--gctc-muted); font-size: 12.5px; font-weight: 500; }
.gctc-code { margin: 0; padding: 10px 12px; overflow-x: auto; border: 1px solid var(--gctc-line); border-radius: 8px; background: #050506; color: #d6d7db; font: 12.5px/1.65 ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.gctc-kw { color: var(--gctc-accent); }
.gctc-str { color: #e9c98a; }
.gctc-num { color: #7ee0b0; }
@keyframes gctc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .gctc-root[data-state="running"] .gctc-glyph { animation: none; } .gctc-panel, .gctc-panel[data-open="true"], .gctc-chevron { transition: none; } }
`;
const WORDS: Record<AiToolCallState, string> = {
running: "En curso",
success: "Completada",
error: "Falló",
pending: "En espera",
};
const GLYPHS: Record<AiToolCallState, ReactNode> = {
running: <path d="M8 1.75a6.25 6.25 0 1 0 6.25 6.25" />,
success: <><circle cx="8" cy="8" r="6.25" /><path d="M5.2 8.2l2 2 3.6-4" /></>,
error: <><circle cx="8" cy="8" r="6.25" /><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4" /></>,
pending: <circle cx="8" cy="8" r="6.25" strokeDasharray="2.2 2.8" />,
};
const TOKENS = /(\b(?:SELECT|FROM|WHERE|AND|OR|ORDER BY|LIMIT|JOIN|ON|AS|DESC|ASC)\b|'[^']*'|"[^"]*"|\b\d+(?:\.\d+)?\b)/g;
function highlight(code: string): ReactNode[] {
return code.split(TOKENS).map((part, i) => {
if (!part) return null;
if (/^(SELECT|FROM|WHERE|AND|OR|ORDER BY|LIMIT|JOIN|ON|AS|DESC|ASC)$/.test(part)) return <span key={i} className="gctc-kw">{part}</span>;
if (/^['"]/.test(part)) return <span key={i} className="gctc-str">{part}</span>;
if (/^\d/.test(part)) return <span key={i} className="gctc-num">{part}</span>;
return part;
});
}
export function AiToolCall({
tool = "consultar_pedidos",
state = "success",
args = "SELECT folio, cliente, dias_retraso FROM pedidos WHERE estado = 'retrasado' ORDER BY dias_retraso DESC LIMIT 10",
result = "3 pedidos encontrados",
duration = "0.12 s",
className,
style,
}: AiToolCallProps) {
const panelId = useId();
const [open, setOpen] = useState(state === "error");
return (
<>
<style>{CSS}</style>
<div className={["gctc-root", className].filter(Boolean).join(" ")} style={style} data-state={state}>
<button type="button" className="gctc-head" aria-expanded={open} aria-controls={panelId} onClick={() => setOpen((v) => !v)}>
<svg className="gctc-glyph" viewBox="0 0 16 16" aria-hidden="true">{GLYPHS[state]}</svg>
<span className="gctc-name">{tool}</span>
<span className="gctc-word">{WORDS[state]}</span>
<span className="gctc-time">{duration}</span>
<svg className="gctc-chevron" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 5l4 4 4-4" /></svg>
</button>
<div className="gctc-panel" id={panelId} data-open={open}>
<div className="gctc-clip">
<div className="gctc-body">
<div>
<h4>Argumentos</h4>
<pre className="gctc-code">{highlight(args)}</pre>
</div>
{result && state !== "running" && state !== "pending" && (
<div>
<h4>{state === "error" ? "Error" : "Resultado"}</h4>
<pre className="gctc-code">{result}</pre>
</div>
)}
</div>
</div>
</div>
</div>
</>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-tool-callEjemplo de uso de Llamada a herramienta
Después de copiar ai-tool-call.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AiToolCall } from "./ai-tool-call";
export default function Ejemplo() {
return <AiToolCall state="running" tool="Ejemplo" />;
}Propiedades de AiToolCall
AiToolCall acepta 7 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
tool? | string | Opcional. |
state? | AiToolCallState | Opcional. |
args? | string | Opcional. |
result? | string | Opcional. |
duration? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
state:running,success,error,pending
Personaliza Llamada a herramienta 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 130 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Llamada a herramienta 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).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Cuatro estados (en espera, en curso, completada, falló) con icono dibujado. Los argumentos resaltan la sintaxis SQL y un error se abre solo para mostrar el motivo.
Incluye
Un solo archivo React + TypeScript con estilos dentro. Sin Tailwind ni dependencias externas.
- Cuatro estados con icono SVG
- Argumentos con SQL resaltado
- Resultado o error desplegable
- Duración tabular
- Respeta prefers-reduced-motion