Startseite / Artikel

Erstellung von mehrstufigen UIs für KI-Agenten mit Next.js und dem AI SDK

Eine UI für einen KI-Agenten in Next.js mit dem AI SDK: vorgefertigte Tools, mehrstufige Schleifen sowie streambasierte generative UI-Komponenten, die während der Arbeit des Agenten in TypeScript dargestellt werden.

8 Min. Lesezeit1868 Wörter

Die meisten Anleitungen zu KI-Systemen enden genau dort, wo es wirklich wichtig wird. Man schreibt einen kleinen Script, sendet eine Anfrage an ein Modell und beobachtet, wie Tokens in einer Terminalansicht angezeigt werden – und das wirkt bereits wie Fortschritt.

Die eigentliche Schwierigkeit zeigt sich erst, wenn man versucht, daraus ein echtes Produkt zu machen.

Dann wird klar, dass ein Geschäftsanwender kein Interesse an einem rohen Block aus Markdown-Text hat. Er erwartet interaktive Datentabellen, eine Bestätigungsstufe vor jeder zerstörerischen Aktion sowie einen reibungslosen Stream-Ausgabe-Verlauf, der nicht dazu führt, dass die Seite ständig springt.

Was er tatsächlich braucht, ist ein Modell, das mehrere Toolaufrufe nacheinander koordinieren kann, während die Benutzeroberfläche für jede Phase dieses Prozesses ordnungsgemäß formatierte React-Komponenten anzeigt.

Das Entwerfen einer Schnittstelle für einen Agenten bedeutet, das Modell als aktiven Ausführungsmotor und nicht als einfachen Textgenerator zu betrachten. Im Folgenden finden Sie einen Entwurf zur Erstellung einer für die Produktion geeigneten Agentenschnittstelle in Next.js mit dem Vercel AI SDK – einer Schnittstelle, die mehrstufige Aufrufe von Tools, stark typisierte Datenpakete sowie dynamisch generierte UI-Elemente unterstützt, sobald die Antworten eintreffen.

Die Architektur: Jenseits einfacher Textströme

Ein robuster Agentenpipeline sieht überhaupt nicht wie ein einfacher Chatbot aus:

[ 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)
  • Stateful Transport: Der Client sendet die Eingaben des Benutzers an einen Route-Handler, der auf dem Edge- oder Node-Backend läuft.
  • Ausführung von Tools auf Serverseite: Das Modell prüft, ob es bereits genügend Informationen hat, um zu antworten, oder ob es ein vordefiniertes Tool aufriefen muss, das über ein Zod-Schema validiert wurde, um externe Daten abzurufen.
  • Mehrschrittige Agentenschleifen: Sobald ein Tool auf dem Server seine Ausführung beendet hat, wird sein Ergebnis automatisch an das Modell zurückgegeben – unter Berücksichtigung der im SDK konfigurierten Schrittbegrenzungen – bis schließlich eine endgültige Antwort in natürlicher Sprache erzeugt wird.
  • Typisierte generative Benutzeroberfläche: Anstatt rohes JSON anzuzeigen, wählt der Client typisierte Tool-Teile aus dem Datenstrom aus und rendernt dafür spezielle, interaktive React-Komponenten.
  • 1. Definition der Agent-API mit typisierten Tools

    Beginnen Sie damit, die API-Route mit dem App Router zu erstellen. Ziel ist ein Agent, der aktuelle Kontoinformationen abrufen und transaktionale Aktionen auslösen kann, wie beispielsweise die Erstellung einer Rechnung.

    Installieren Sie die erforderlichen Pakete:

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

    Dann definieren Sie den Route-Handler in 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();
    }
    

    Warum maxSteps wichtig ist

    Ältere Ansätze für mehrstufiges Agenten-Reasoning setzten auf umständliche while-Schleifen, manuelle Änderungen am Nachrichtenarray sowie getrennte Anfragen an den LLM für jeden Schritt.

    Durch die Einstellung von maxSteps: 5 kann das SDK diesen rekursiven Zyklus zur Auflösung von Tools auf dem Server übernehmen und jeden Zwischenzustand direkt an den Client streamen, sobald er entsteht.

    2. Aufbau des Clients: Streaming von typisierten Komponenteninseln

    Auf der Frontend-Seite teilen moderne Agenten-Interfaces jede eingehende Antwort in Teile auf, anstatt sie als einen einzigen Textblock zu behandeln. Eine einzelne Antwort kann normale Textabschnitte, noch ausstehende Toolaufrufe sowie bereits abgeschlossene Toolergebnisse enthalten.

    Richten Sie den Chat-Container in app/page.tsx ein:

    '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. Darstellung generativer UI-Komponenten

    Anstatt rohes JSON auszugeben, werden die Ergebnisse des Schemas direkt auf Präsentationskomponenten abgebildet.

    Hier ist 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>
      );
    }
    

    Und 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. Erfahrene Lektionen aus der Produktion

    Das Übertragen einer solchen Schnittstelle von localhost auf eine Live-Deployment führt oft schnell zu Randfällen. Behalten Sie die folgenden Schutzmaßnahmen im Hinterkopf.

    1. Zeitlimits für Serveranfragen auf Vercel festlegen

    Agentenschleifen, die mehrere Toolaufrufe miteinander verknüpfen, können leicht das Standardzeitlimit für serverlose Funktionen überschreiten (in der Regel 10 bis 15 Sekunden). Stellen Sie sicher, dass Sie in Ihrer App Router-Route eine explizite Dauerangabe festlegen:

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

    2. Idempotenz bei Toolausführungen

    Falls ein Modell seine eigene Ausgabe falsch interpretiert oder aufgrund unklarer Anweisungen einen Schritt erneut ausführt, kann es dazu kommen, dass ein Mutationstool mehrmals aufgerufen wird. Jedes Tool, das Daten schreibt – beispielsweise eines, das eine Zahlungsmethode abruft oder ein neues Kontoinformationen erstellt – sollte eine Idempotenzschlüssel benötigen, der entweder direkt aus der Anfrage selbst stammt oder aus dem Sitzungsidentifikator abgeleitet wird.

    3. Bestätigung durch einen Menschen

    Ein Agent sollte niemals autonom irreversible Operationen durchführen dürfen, wie zum Beispiel das Löschen von Datensätzen, das Versenden echter E-Mails oder das Überweisen von Geldern.

    • Lassen Sie den Agenten stattdessen einen Entwurfszustand erstellen, der mit etwas wie status: 'pending_approval' markiert ist.
    • Ziehen Sie eine interaktive Karte hinzu, die einen manuellen Bestätigungsbutton enthält.
    • Erfordern Sie, dass der Benutzer die Aktion explizit genehmigt und über eine standardmäßige Next.js Server Action auslöst.

    Der neue Standard für KI-Interfaces

    Einfache Chat-Vervollständigungsfelder gehören allmählich der Vergangenheit an. Die heutigen Nutzer suchen keinen abstrakten Gesprächspartner; sie wollen Tools, die Reibungsverluste im Arbeitsablauf verringern, ohne ihnen die Kontrolle zu entziehen.

    Durch die Kombination von Next.js App Router Streaming mit dem typisierten Tool-Runtime des AI SDKs verwandeln Sie Sprachmodelle in zuverlässige funktionale Bausteine anstelle von unvorhersehbaren Textgeneratoren.

    Das Ergebnis ist ein System, das die Vorhersehbarkeit stark typisierter TypeScript-Komponenten mit der flexiblen Denkfähigkeit von Foundation-Modellen verbindet – genau das Gleichgewicht, das Unternehmenssoftware benötigt.