GenAINextVercel AI SDKLangchainGoogle Gemini

Fundamentos modernos y primera interacción con IA. Parte 3

Antonio Pérez
2025-08-14
7
Fundamentos modernos y primera interacción con IA. Parte 3

Uniendo las piezas: Creando la interfaz de usuario

En esta sección final, construiremos la interfaz de usuario (UI) que consumirá nuestra Server Action generateClickbaitTitles. Aplicaremos los patrones de composición de componentes de React que discutimos en la teoría, creando una experiencia de usuario fluida a pesar de la potencial latencia de la llamada a la IA. Nuestra UI será un Client Component, ya que necesita manejar el estado del formulario, la entrada del usuario y la respuesta de la acción.

Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/fundamentals-ai-part3

Paso 1: Crear el componente de la página principal

Vamos a modificar el archivo src/app/page.tsx para que contenga nuestro formulario y el área de visualización de resultados.

Reemplaza el contenido de src/app/page.tsx con el siguiente código:

typescriptsrc/app/page.tsx
'use client';

import { useState } from 'react';
import { generateClickbaitTitles } from '@/lib/ai/actions';
import type { z } from 'zod';
import type { AITitlesOutputSchema } from '@/lib/validations/schemas';

// Definimos un tipo local para el estado de los resultados
type TitlesData = z.infer<typeof AITitlesOutputSchema>;

export default function HomePage() {
  // Estado para manejar el estado de carga de la Server Action
  const [isLoading, setIsLoading] = useState<boolean>(false);
  // Estado para almacenar los títulos generados por la IA
  const [titlesData, setTitlesData] = useState<TitlesData | null>(null);
  // Estado para manejar cualquier error devuelto por la acción
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setIsLoading(true);
    setError(null);
    setTitlesData(null);

    const formData = new FormData(event.currentTarget);
    const articleUrl = formData.get('articleUrl') as string;

    const result = await generateClickbaitTitles({ articleUrl });

    if (result.success) {
      setTitlesData(result.data);
    } else {
      setError(result.error);
    }

    setIsLoading(false);
  };

  return (
    <main className="container mx-auto max-w-2xl px-4 py-12">
      <h1 className="text-4xl font-bold text-center mb-8 text-amber-400">
        Generador de títulos virales con IA
      </h1>
      <div className='p-5 border-2 rounded-2xl bg-slate-700 border-slate-500'>
        <form onSubmit={handleSubmit} className="flex flex-col gap-4">
          <label htmlFor="articleUrl" className="font-semibold">
            URL del Artículo
          </label>
          <input
            id="articleUrl"
            name="articleUrl"
            type="url"
            required
            placeholder="https://ejemplo.com/mi-articulo-genial"
            className="p-2 border rounded-md shadow-sm bg-slate-300 text-gray-900 focus:ring-amber-400"
            disabled={isLoading}
          />
          <button
            type="submit"
            className="bg-amber-400 text-gray-900 font-bold py-2 px-4 rounded-md hover:bg-amber-500 disabled:bg-gray-400 transition-colors"
            disabled={isLoading}
          >
            {isLoading ? 'Generando...' : 'Generar Títulos'}
          </button>
        </form>

        {/* Sección de Resultados y Errores */}
        <div className="mt-10">
          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-md" role="alert">
              <strong className="font-bold">Error: </strong>
              <span className="block sm:inline">{error}</span>
            </div>
          )}

          {titlesData && (
            <div className="bg-slate-300 text-gray-800 p-6 rounded-lg shadow-md">
              <h2 className="text-xl font-semibold mb-4">Sugerencias de Títulos:</h2>
              <ul className="list-disc list-inside space-y-2">
                {titlesData.titles.map((title, index) => (
                  <li key={index} className="text-shadow-md">
                    {title}
                  </li>
                ))}
              </ul>
            </div>
          )}

          {isLoading && (
            <div className="text-center pt-8">
              <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-400 mx-auto"></div>
              <p className="mt-4 text-gray-200">La IA está pensando... por favor espera.</p>
            </div>
          )}
        </div>
      </div>
    </main>
  );
}

Paso 2: Añadir estilos básicos

Para que el spinner de carga funcione, añade la animación spin a tu globals.css si no estuviera ya definida por Tailwind CSS.

csssrc/app/globals.css
@import "tailwindcss";

:root {
  --background: #1d293d;
  --foreground: #ffffff;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: Arial, Helvetica, sans-serif;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }
}

.animate-spin {
  animation: spin 1s linear infinite;
}

Análisis del componente de UI

'use client': Es la primera y más importante línea. Declara la frontera entre el servidor y el cliente. Todo este archivo y sus dependencias (excluyendo las Server Actions) se ejecutarán en el navegador.

Manejo de estado (useState):

  • isLoading: Un booleano simple pero efectivo para gestionar el estado de la UI durante la petición. Deshabilita el formulario para prevenir envíos duplicados y muestra un indicador de carga.
  • titlesData: Almacena la respuesta exitosa. Su tipo, TitlesData, se infiere directamente de nuestro schema AITitlesOutputSchema, manteniendo la consistencia de tipos de extremo a extremo.
  • error: Almacena el mensaje de error de la Server Action, permitiéndonos mostrar feedback útil al usuario.

Invocación de la Server Action:

La línea const result = await generateClickbaitTitles({ articleUrl }); es donde ocurre la magia.

  • Invocación Directa: Observa que no hay fetch, no hay URLs de API, no hay useEffect. Importamos generateClickbaitTitles como si fuera una función local y la llamamos directamente. Next.js y React se encargan de la comunicación de red por debajo. Esto es una de las mayores ventajas de la arquitectura del App Router.
  • Manejo de la Respuesta: El objeto discriminado que diseñamos en nuestra Server Action ({ success, data | error }) hace que el manejo de la respuesta en el cliente sea limpio, predecible y seguro.

Renderizado condicional:

La UI reacciona a los diferentes estados (isLoading, error, titlesData) para mostrar siempre la información más relevante: el formulario, un spinner, un mensaje de error o los resultados. Este es un patrón fundamental en el desarrollo de UIs modernas y robustas.

1.12 Ejecución y verificación final

Guarda todos los archivos y ejecuta tu aplicación de desarrollo:

bash
pnpm dev

Navega a http://localhost:3000. Deberías ver el formulario.

Pantalla inicial

Prueba los siguientes escenarios:

  1. Caso de éxito: Introduce una URL válida (ej. https://vercel.com/blog/ai-sdk-5) y haz clic en "Generar Títulos". Verás el spinner.

Captura del spinner

Después de unos segundos, aparecerán los tres títulos generados por Gemini.

Pantalla con los resultados

2. Error de validación: Intenta enviar una URL inválida (ej. texto-invalido). La Server Action devolverá el error de validación de Zod, y lo verás mostrado en la UI.

Error de comunicación

Este error también aparece si hay un problema de comunicación con el proveedor o si la API_KEY es incorrecta. En un entorno de producción debería tener un sistema de logs que recojan toda esta información, pero de momento podemos consultar la terminal para detectar el error.

Error de comunicación en el terminal

3. Estado de carga: En una conexión lenta (puedes simularlo en las herramientas de desarrollador de tu navegador), verás que el formulario se deshabilita y el spinner se muestra hasta que la respuesta llega.

Conclusión del capítulo 1

¡Felicidades! Has construido y desplegado tu primera aplicación de IA Full-Stack completa. Aunque la funcionalidad es simple, la arquitectura subyacente es increíblemente potente y escalable.

Repasemos los logros clave de este capítulo desde una perspectiva senior:

  • Arquitectura sólida: Has implementado una separación clara entre la lógica de servidor (Server Action) y la presentación (Client Component), utilizando el paradigma de Next.js App Router.
  • Contratos de datos inquebrantables: Has utilizado Zod para definir esquemas que garantizan la integridad de los datos desde la entrada del usuario hasta la salida de la IA, previniendo una clase entera de errores de runtime.
  • Interacción robusta con la IA: Has utilizado generateObject del Vercel AI SDK para asegurar que la respuesta del LLM sea siempre estructurada y validada, eliminando la fragilidad del parseo manual de JSON.
  • Experiencia de usuario consciente: Has manejado los estados de carga y error, proporcionando feedback claro al usuario, un aspecto crucial cuando se trabaja con operaciones asíncronas potencialmente lentas.

Has sentado los cimientos. Cada uno de los conceptos que hemos implementado aquí —Server Actions, validación con Zod, streaming (que exploraremos a fondo), y la abstracción del SDK— será una pieza fundamental en los sistemas más complejos que construiremos en los próximos capítulos.

Puedes encontrar el código de esta sección en: https://github.com/aperezl/ai-fullstack-serie/tree/fundamentals-ai-part3

Ahora que dominas la interacción básica, estás listo para llevar tus aplicaciones al siguiente nivel: la conversación en tiempo real.