Creación de interfaces de usuario para agentes de IA multi-paso con Next.js y el SDK de IA
Una interfaz de usuario para agentes de IA en Next.js con el SDK de IA: herramientas tipadas, bucles de múltiples pasos y componentes de interfaz de usuario generativos en tiempo real que se renderizan a medida que el agente trabaja, en TypeScript.
La mayoría de los tutoriales sobre sistemas de IA se detienen justo en el punto en que las cosas comienzan a ser importantes. Escribes un pequeño script, envías una instrucción a un modelo y observas cómo los tokens se imprimen en la terminal; eso ya parece progreso.
La verdadera dificultad aparece cuando intentas convertir eso en un producto real.
Entonces se hace evidente que al usuario empresarial no le interesa un bloque bruto de texto Markdown. Esperan tablas interactivas de datos, un paso de confirmación antes de que se ejecute cualquier acción destructiva, y una salida en streaming fluida que no haga que la página salte de un lugar a otro.
Lo que realmente necesitan es un modelo capaz de coordinar varias llamadas a herramientas en secuencia, mientras la interfaz renderiza componentes React correctamente formateados para cada etapa de ese proceso.
Diseñar una interfaz para un agente implica tratar el modelo como un motor de ejecución activo y no como un simple generador de texto. A continuación se presenta un esquema para crear una interfaz de agente de nivel profesional en Next.js con el Vercel AI SDK: una que soporta la invocación de herramientas en múltiples pasos, cargas útiles con tipado estricto y elementos de interfaz generados dinámicamente a medida que llegan las respuestas.
La arquitectura: más allá de los simples flujos de texto
Un pipeline de agente robusto no se parece en nada a un chatbot básico:
[ Client: useChat() ]
│ ▲
│ │ Data Stream (Tokens + Typed Tool Invocations)
▼ │
[ Next.js Route Handler: streamText() ]
│ ▲
│ │ Model generates tool arguments
▼ │
[ Tool Execution (e.g. DB Query / Stripe API) ]
│
└──> (Loop back to LLM if more steps are required)
- Transporte con estado: el cliente envía la entrada del usuario a un manejador de rutas que se ejecuta en el entorno Edge o Node.
- Ejecución de herramientas del lado del servidor: el modelo determina si ya cuenta con suficiente información para responder, o si debe llamar a una herramienta predefinida, validada mediante un esquema Zod, para obtener datos externos.
1. Definir la API del agente con herramientas tipadas
Comience creando la ruta de API con App Router. El objetivo es contar con un agente que pueda obtener análisis en tiempo real de la cuenta y ejecutar acciones transaccionales, como generar una factura.
Instale los paquetes necesarios:
npm install ai @ai-sdk/openai @ai-sdk/react zod
Luego defina el manejador de la ruta dentro de app/api/agent/route.ts:
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
// Prevent function timeouts on long agent loops
export const maxDuration = 60;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
// System instructions establish agent personality and boundaries
system: `You are an internal operations assistant.
Use the available tools to answer queries and perform operations.
Always confirm parameters before invoking actions that write data.`,
// Allow the model to execute a tool, inspect output, and take subsequent steps
maxSteps: 5,
tools: {
getAccountMetrics: tool({
description: 'Fetch real-time metrics for a customer account',
parameters: z.object({
accountId: z.string().describe('The alphanumeric account identifier'),
timeframe: z.enum(['7d', '30d', '90d']),
}),
execute: async ({ accountId, timeframe }) => {
// Replace with real database or external API call
await new Promise((r) => setTimeout(r, 800)); // Simulating latency
return {
accountId,
timeframe,
revenue: 24500,
activeUsers: 342,
churnRisk: 'low',
};
},
}),
generateInvoiceDraft: tool({
description: 'Prepare an invoice draft for customer review',
parameters: z.object({
recipientEmail: z.string().email(),
amount: z.number().positive(),
currency: z.string().default('USD'),
}),
execute: async ({ recipientEmail, amount, currency }) => {
// Simulating invoice creation
return {
invoiceId: `inv_${Math.random().toString(36).substring(2, 9)}`,
status: 'draft',
recipientEmail,
amount,
currency,
createdAt: new Date().toISOString(),
};
},
}),
},
});
return result.toDataStreamResponse();
}
Por qué es importante maxSteps
Los enfoques anteriores para el razonamiento de agentes con múltiples pasos dependían de bucles while poco eficientes, ediciones manuales en el array de mensajes y viajes separados al LLM para cada paso.
Al establecer maxSteps: 5, el SDK puede asumir ese ciclo recursivo de resolución de herramientas en el servidor, transmitiendo cada estado intermedio directamente al cliente a medida que ocurre.
2. Construcción del cliente: Transmisión en tiempo real de componentes tipados
En el frontend, las interfaces de agentes modernas dividen cada respuesta recibida en partes en lugar de tratarla como un único bloque de texto. Una sola respuesta puede mezclar segmentos de texto plano, llamadas a herramientas que aún están en proceso y resultados de herramientas que ya se han completado.
Configura el contenedor de chat dentro de app/page.tsx:
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { MetricsCard } from '@/components/tools/MetricsCard';
import { InvoiceCard } from '@/components/tools/InvoiceCard';
export default function AgentConsole() {
const [input, setInput] = useState('');
const { messages, append, status } = useChat({
api: '/api/agent',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
append({ role: 'user', content: input });
setInput('');
};
const isLoading = status === 'streaming' || status === 'submitted';
return (
<main className="max-w-4xl mx-auto py-10 px-4 flex flex-col h-screen">
<header className="border-b pb-4 mb-6">
<h1 className="text-xl font-semibold tracking-tight">Operations Intelligence Agent</h1>
<p className="text-sm text-neutral-500">Autonomous workflow orchestration and metrics exploration.</p>
</header>
{/* Message Feed */}
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
{messages.map((m) => (
<div
key={m.id}
className={`flex flex-col ${
m.role === 'user' ? 'items-end' : 'items-start'
}`}
>
<div
className={`rounded-2xl px-4 py-3 max-w-[85%] text-sm ${
m.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-neutral-100 text-neutral-900 border border-neutral-200'
}`}
>
{/* Handle granular message parts */}
{m.parts?.map((part, index) => {
if (part.type === 'text') {
return <p key={index} className="whitespace-pre-wrap">{part.text}</p>;
}
// Handle tool rendering based on typed part identifier
if (part.type === 'tool-invocation') {
const { toolName, state, args } = part.toolInvocation;
if (state !== 'result') {
return (
<div key={index} className="my-2 py-2 px-3 bg-neutral-50 rounded border border-dashed border-neutral-300 text-xs text-neutral-500 animate-pulse">
Executing tool: <code className="font-mono">{toolName}</code> with {JSON.stringify(args)}...
</div>
);
}
const { result } = part.toolInvocation;
// Render specialized components for each tool
if (toolName === 'getAccountMetrics') {
return <MetricsCard key={index} data={result} />;
}
if (toolName === 'generateInvoiceDraft') {
return <InvoiceCard key={index} data={result} />;
}
}
return null;
}) ?? m.content}
</div>
</div>
))}
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className="mt-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask for metrics or draft an invoice..."
disabled={isLoading}
className="flex-1 rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading}
className="bg-neutral-900 text-white rounded-lg px-5 py-2 text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 transition"
>
{isLoading ? 'Thinking...' : 'Send'}
</button>
</form>
</main>
);
}
3. Renderizado de componentes UI generativos
En lugar de enviar JSON sin procesar, se mapea directamente la salida del esquema a los componentes de presentación.
Aquí está components/tools/MetricsCard.tsx:
interface MetricsCardProps {
data: {
accountId: string;
timeframe: string;
revenue: number;
activeUsers: number;
churnRisk: string;
};
}
export function MetricsCard({ data }: MetricsCardProps) {
return (
<div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
<div className="flex items-center justify-between pb-2 border-b border-neutral-100 mb-3">
<span className="font-mono text-xs text-neutral-400">ID: {data.accountId}</span>
<span className="bg-emerald-50 text-emerald-700 text-xs px-2 py-0.5 rounded-full font-medium">
Window: {data.timeframe}
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Revenue</p>
<p className="text-lg font-bold">${data.revenue.toLocaleString()}</p>
</div>
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Users</p>
<p className="text-lg font-bold">{data.activeUsers}</p>
</div>
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Churn Risk</p>
<p className="text-lg font-bold capitalize text-emerald-600">{data.churnRisk}</p>
</div>
</div>
</div>
);
}
Y components/tools/InvoiceCard.tsx:
interface InvoiceCardProps {
data: {
invoiceId: string;
status: string;
recipientEmail: string;
amount: number;
currency: string;
createdAt: string;
};
}
export function InvoiceCard({ data }: InvoiceCardProps) {
return (
<div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
<div className="flex justify-between items-center mb-2">
<h4 className="text-xs uppercase font-bold tracking-wider text-neutral-400">Invoice Draft Created</h4>
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded font-mono font-medium">
{data.status}
</span>
</div>
<p className="text-xl font-bold font-mono my-1">
${data.amount} <span className="text-xs font-sans text-neutral-400">{data.currency}</span>
</p>
<p className="text-xs text-neutral-500">Recipient: <span className="text-neutral-700">{data.recipientEmail}</span></p>
<div className="mt-3 flex gap-2">
<button
onClick={() => alert(`Invoice ${data.invoiceId} sent!`)}
className="w-full bg-neutral-900 hover:bg-neutral-800 text-white text-xs font-semibold py-1.5 rounded transition"
>
Approve & Send
</button>
</div>
</div>
);
}
4. Lecciones adquiridas en entornos de producción
Transferir una interfaz como esta desde localhost a un despliegue en vivo suele hacer que surjan casos límite rápidamente. Tenga en cuenta las siguientes medidas de protección.
1. Tiempos de espera del servidor en Vercel
Los bucles de agente que combinan varias llamadas a herramientas pueden superar fácilmente el período de tiempo de espera predeterminado para las funciones serverless (generalmente de 10 a 15 segundos). Asegúrese de declarar un valor explícito para la duración en su ruta de App Router:
export const maxDuration = 60; // Set according to your deployment plan limits
2. Identempotencia en la ejecución de herramientas
Cuando un modelo interpreta incorrectamente su propio resultado o vuelve a ejecutar un paso debido a instrucciones poco claras, es posible que acabe invocando una herramienta de mutación más de una vez. Cualquier herramienta que realice operaciones de escritura, como aquellas que cobran mediante un método de pago o crean un nuevo registro de cuenta, debe requerir una clave de idempotencia proporcionada ya sea desde el propio prompt o derivada del identificador de sesión.
3. Confirmación con intervención humana
Nunca se debe permitir que un agente realice de forma autónoma operaciones irreversibles, como eliminar registros, enviar correos electrónicos reales o transferir dinero.
- Hacer que el agente genere un estado preliminar, marcado con algo como
status: 'pending_approval'. - Mostrar una tarjeta interactiva que incluya un botón de confirmación manual.
- Requerir que el usuario apruebe explícitamente y active la acción a través de una Acción de Servidor Next.js estándar.
El nuevo estándar para interfaces de IA
Los cuadros simples de completación de chat están pasando a ser cosa del pasado. Los usuarios de hoy en día no buscan un compañero de conversación sin rostro; quieren herramientas que reduzcan las fricciones en el flujo de trabajo mientras les permiten mantener el control total.
Al combinar la transmisión en tiempo real de Next.js App Router con el entorno de ejecución tipado del SDK de IA, se convierten los modelos de lenguaje en bloques funcionales fiables en lugar de generadores de texto impredecibles.
El resultado es un sistema que une la previsibilidad de los componentes TypeScript fuertemente tipados con el razonamiento flexible de los modelos fundamentales, que es precisamente el equilibrio que necesita el software empresarial.
Lecturas relacionadas
- ReAct explicado: Cómo los agentes de IA combinan el razonamiento con acciones del mundo real — Aprenda cómo el marco ReAct integra el razonamiento y el uso de herramientas para potenciar a los agentes de IA, y cómo difiere de los modelos Chain-of-Thought, RL y de razonamiento.
- Convertir los manejadores de ruta de Next.js en una capa BFF intencional — Aprenda qué soluciona el patrón Backend for Frontend, por qué vuelve a utilizarse en las aplicaciones Next.js y cómo evitar que los manejadores de ruta se conviertan en objetos excesivamente complejos.