Accueil / Articles

Création d’interfaces utilisateur pour des agents IA à plusieurs étapes avec Next.js et le SDK AI

Une interface utilisateur pour un agent d’IA dans Next.js avec le SDK d’IA : outils typés, boucles multi-étapes et composants d’interface utilisateur génératifs en flux affichés au fur et à mesure que l’agent travaille, en TypeScript.

8 min de lecture1868 mots

La plupart des tutoriels sur les systèmes d’IA s’arrêtent au moment où les choses commencent à devenir importantes. On écrit un petit script, on envoie une requête à un modèle, et on observe les tokens s’afficher dans une terminal — ce qui semble déjà être un progrès.

La véritable difficulté apparaît lorsque l’on tente de transformer cela en un produit réel.

C’est alors que l’on se rend compte qu’un utilisateur professionnel n’a aucun intérêt pour un bloc brut de texte Markdown. Il attend des tableaux de données interactifs, une étape de confirmation avant toute action destructrice, ainsi qu’une sortie fluide qui ne fait pas sauter la page.

Ce dont il a vraiment besoin, c’est d’un modèle capable de coordonner plusieurs appels à des outils en séquence, tandis que l’interface affiche des composants React correctement formatés pour chaque étape de ce processus.

Concevoir une interface pour un agent signifie traiter le modèle comme un moteur d’exécution actif plutôt que comme un simple générateur de texte. Ci-dessous se trouve un plan pour créer une interface d’agent de niveau production dans Next.js à l’aide du Vercel AI SDK — une interface qui prend en charge l’appel de outils en plusieurs étapes, des chargements de données fortement typés, ainsi que des éléments UI générés dynamiquement au fur et à mesure que les réponses arrivent.

L’architecture : aller au-delà des flux de texte simples

[ 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)
  • Transport à état : le client envoie les entrées de l’utilisateur à un gestionnaire de route exécuté sur Edge ou Node.
  • Exécution des outils du côté serveur : le modèle détermine s’il dispose déjà de suffisamment d’informations pour répondre, ou s’il doit appeler un outil prédéfini, validé via un schéma Zod, afin de récupérer des données externes.
  • Boucles d’agent à plusieurs étapes : une fois qu’un outil a terminé son exécution sur le serveur, son résultat est automatiquement transmis au modèle — conformément aux limites d’étapes configurées dans le SDK — jusqu’à ce que celui-ci produise une réponse finale en langue naturelle.
  • Interface utilisateur générative typée : au lieu d’afficher du JSON brut, le client sélectionne les segments d’outils typés provenant du flux et affiche pour eux des composants React interactifs dédiés.
  • 1. Définition de l’API de l’agent avec des outils typés

    Commencez par créer la route API à l’aide d’App Router. L’objectif est d’avoir un agent capable de récupérer des analyses en temps réel du compte et de lancer des actions transactionnelles, comme la génération d’une facture.

    Installez les paquets requis :

    npm install ai @ai-sdk/openai @ai-sdk/react zod
    

    Puis définez le gestionnaire de route dans 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();
    }
    

    Pourquoi maxSteps est important

    Les approches anciennes du raisonnement d’agent à plusieurs étapes reposaient sur des boucles while peu pratiques, des modifications manuelles de l’array de messages, ainsi que des allers-retours séparés vers le LLM pour chaque étape.

    En définissant maxSteps: 5, le SDK peut prendre en charge ce cycle récursif de résolution d’outils sur le serveur, transmettant chaque état intermédiaire directement au client au fur et à mesure qu’il est généré.

    2. Construction du client : diffusion en flux continu des îles de composants typés

    Sur le front-end, les interfaces d’agent modernes divisent chaque réponse reçue en parties au lieu de la traiter comme un seul bloc de texte. Une seule réponse peut contenir des segments de texte brut, des appels d’outils en attente de résolution, ainsi que des résultats d’outils déjà prêts.

    Configurez le conteneur de chat à l’intérieur 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. Affichage des composants UI génératifs

    Au lieu de déposer du JSON brut, vous mappez directement la sortie du schéma sur les composants de présentation.

    Voici 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>
      );
    }
    

    Et 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. Leçons acquises au prix de l’expérience en environnement de production

    Passer d’une interface comme celle-ci depuis localhost à un déploiement en ligne a tendance à faire apparaître rapidement des cas limites. Gardez à l’esprit les mesures de précaution suivantes.

    1. Définir des délais d’attente pour le serveur sur Vercel

    Les boucles d’agent qui enchaînent plusieurs appels d’outils peuvent facilement dépasser la fenêtre de délai par défaut des fonctions serverless (généralement de 10 à 15 secondes). Assurez-vous de déclarer une valeur explicite pour la durée dans votre route App Router :

    export const maxDuration = 60; // Set according to your deployment plan limits
    

    2. Idempotence de l’exécution des outils

    Lorsqu’un modèle interprète incorrectement sa propre sortie ou exécute à nouveau une étape en raison d’instructions floues, il peut finir par utiliser un outil de mutation plus d’une fois. Tout outil d’écriture, tel que celui qui demande des informations de paiement ou crée un nouveau enregistrement de compte, doit exiger une clé d’idempotence fournie soit directement dans la demande, soit dérivée de l’identifiant de session.

    3. Confirmation par un humain

    Un agent ne doit jamais être autorisé à effectuer de manière autonome des opérations irréversibles, comme supprimer des enregistrements, envoyer des e-mails réels ou transférer de l’argent.

    • Faire en sorte que l’agent génère plutôt un état provisoire, marqué par quelque chose comme status: 'pending_approval'.
    • Afficher une carte interactive contenant un bouton de confirmation manuelle.
    • Exiger que l’utilisateur approuve explicitement et déclenche l’action via une action serveur Next.js standard.

    Le nouveau standard pour les interfaces d’IA

    Les champs de complétion de chat simples deviennent obsolètes. Les utilisateurs d’aujourd’hui ne cherchent pas un partenaire de conversation abstrait ; ils souhaitent des outils qui réduisent les frictions dans leur flux de travail tout en leur permettant de garder le contrôle.

    En combinant le streaming du Next.js App Router avec l’environnement d’exécution typé de l’AI SDK, vous transformez les modèles de langage en éléments fonctionnels fiables plutôt que en générateurs de texte imprévisibles.

    Le résultat est un système qui allie la prévisibilité des composants TypeScript fortement typés à la capacité de raisonnement flexible des modèles fondamentaux, ce qui correspond précisément à l’équilibre nécessaire pour le logiciel d’entreprise.

    Lectures complémentaires