Strona główna / Artykuły

Tworzenie interfejsów użytkownika wieloetapowych agentów AI za pomocą Next.js i SDK AI

Interfejs użytkownika agenta AI w Next.js z SDK AI: narzędzia typowane, pętle wieloetapowe oraz komponenty interfejsu generatywnego w formie strumieniowej, renderowane w czasie rzeczywistym podczas pracy agenta, napisane w TypeScript.

8 min czytania1868 słów

Większość poradników dotyczących systemów AI kończy się w momencie, gdy sprawy zaczynają nabierać znaczenia. Piszesz mały skrypt, wysyłasz polecenie do modelu i obserwujesz wyświetlanie się tokenów w terminalu — a to już wydaje się postępem.

Prawdziwe trudności pojawiają się, gdy próbujesz przekształcić to w rzeczywisty produkt.

Dopiero wtedy staje się jasne, że użytkownik biznesowy nie jest zainteresowany surowym blokiem tekstu w formacie Markdown. Oczekuje interaktywnych tabel danych, kroku potwierdzenia przed wykonaniem jakiejkolwiek destruktywnej czynności oraz płynnego wyświetlania wyników, które nie powodują przeskakiwania strony.

To, czego naprawdę potrzebują, to model, który potrafi koordynować kilka wywołań narzędzi kolejno, przy czym interfejs renderuje odpowiednio sformatowane komponenty React dla każdego etapu tego procesu.

Projektowanie interfejsu dla agenta oznacza traktowanie modelu jako aktywnego silnika wykonywania zadań, a nie prostego generatora tekstu. Poniżej znajduje się plan budowy interfejsu agenta klasy produkcyjnej w Next.js przy użyciu Vercel AI SDK — takiego, który obsługuje wieloetapowe wywoływanie narzędzi, silnie typowane dane wejściowe oraz elementy interfejsu generowane dynamicznie w miarę przychodzenia odpowiedzi.

Architektura: Poza prostymi strumieniami tekstu

Robustny pipeline agenta w ogóle nie przypomina zwykłego chatbota:

[ 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 z pamięcią stanu: klient wysyła dane wejściowe użytkownika do obsługi trasy działającej na środowisku Edge lub Node.
  • Wykonywanie narzędzi po stronie serwera: model określa, czy posiada już wystarczająco dużo informacji, aby odpowiedzieć, czy też musi wywołać z góry zdefiniowane narzędzie, zweryfikowane za pomocą schematu Zod, w celu pobrania danych z zewnętrznych źródeł.
  • Pętle agenta wieloetapowego: gdy narzędzie zakończy działanie na serwerze, jego wynik jest automatycznie przekazywany z powrotem do modelu — zgodnie z ograniczeniami liczby kroków ustawionymi w SDK — aż model wygeneruje ostateczną odpowiedź w języku naturalnym.
  • Interfejs użytkownika generatywny z typowaniem: zamiast wyświetlać surowy JSON, klient wybiera typowane segmenty narzędzi z strumienia i renderuje dla nich dedykowane, interaktywne komponenty React.
  • 1. Definiowanie API agenta za pomocą narzędzi typowanych

    Najpierw utwórz trasę API za pomocą App Router. Celem jest agent, który może pobierać aktualne dane analityczne konta i uruchamiać operacje transakcyjne, takie jak generowanie faktury.

    Zainstaluj wymagane pakiety:

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

    Następnie zdefiniuj obsługę trasy w pliku 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();
    }
    

    Dlaczego maxSteps jest ważny

    Starsze podejścia do wieloetapowego rozumowania agentów opierały się na niezgrabnych pętlach while, ręcznych modyfikacjach tablicy wiadomości oraz osobistych wyjazdach do LLM na każdy etap.

    Ustawienie maxSteps: 5 pozwala SDK przejąć ten rekurencyjny cykl rozwiązywania zadań na serwerze, przesyłając każdy pośredni stan bezpośrednio do klienta w momencie jego powstania.

    2. Budowa klienta: strumieniowanie typowanych komponentów

    W interfejsie użytkownika współczesne interfejsy agentów dzielą każdą przychodzącą odpowiedź na części, zamiast traktować ją jako jeden blok tekstu. Jedna odpowiedź może zawierać fragmenty prostego tekstu, wywołania narzędzi wciąż oczekujące na rozwiązanie oraz wyniki narzędzi, które już zostały ukończone.

    Ustaw kontener czatu w pliku 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. Wyświetlanie generatywnych komponentów interfejsu

    Zamiast wysyłać surowy JSON, mapuje się wyniki schematu bezpośrednio na komponenty prezentacyjne.

    Oto plik 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>
      );
    }
    

    A plik 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. Doświadczenia zdobyte w produkcji

    Przenoszenie takiej interfejsu z localhost na działającą implementację często szybko ujawnia przypadki krawędziowe. Pamiętaj o następujących środkach ostrożności.

    1. Ograniczenia czasowe serwera na Vercel

    Pętle agenta, które łączą ze sobą kilka wywołań narzędzi, mogą łatwo przekroczyć domyślny czas oczekiwania dla funkcji serverless (zwykle 10 do 15 sekund). Upewnij się, że w swojej trasie App Router zadeklarujesz wyraźną wartość czasu trwania:

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

    2. Idempotencja wykonywania narzędzi

    Gdy model błędnie interpretuje swój własny wynik lub ponownie wykonywać jakiś krok z powodu niejasnych instrukcji, może to skutkować wywołaniem narzędzia mutacji więcej niż raz. Każde narzędzie służące do zapisu danych, takie jak to, które pobiera opłatę za pomocą metody płatności lub tworzy nowy rekord konta, powinno wymagać klucza idempotencji dostarczonego albo bezpośrednio z samej instrukcji, albo wygenerowanego na podstawie identyfikatora sesji.

    3. Potwierdzenie przez człowieka

    Agent nie powinien nigdy mieć możliwości samodzielnie wykonywania operacji nieodwracalnych, takich jak usuwanie rekordów, wysyłanie prawdziwych e-maili czy przenoszenie pieniędzy.

    • Należy, aby agent stworzył zamiast tego wersję roboczą stanu, oznaczoną czymś w rodzaju status: 'pending_approval'.
    • Należy pokazać interaktywną kartę zawierającą przycisk ręcznego potwierdzenia.
    • Należy zażądać od użytkownika wyraźnej akceptacji i uruchomienia działania za pomocą standardowej funkcji Next.js Server Action.

    Nowy standard interfejsów AI

    Zwykłe pola do uzupełniania w rozmowach stają się przeszłością. Dzisiejsi użytkownicy nie szukają abstrakcyjnego partnera do rozmowy; chcą narzędzi, które zmniejszają opór w procesach pracy, pozwalając im jednocześnie zachować pełną kontrolę.

    Łącząc streaming w Next.js App Router z typowanym środowiskiem wykonywania narzędzi SDK AI, przekształcasz modele językowe w niezawodne elementy budulcowe, a nie nieprzewidywalne generatorzy tekstu.

    Rezultatem jest system, który łączy przewidywalność silnie typowanych komponentów TypeScript z elastycznym rozumowaniem modeli podstawowych – to dokładnie ten balans, którego potrzebuje oprogramowanie korporacyjne.

    Powiązane materiały

  • Auto-generuj szyfrowany klient API Next.js z NestJS Swagger — Dowiedz się, jak usunąć duplikowane typy API przy użyciu NestJS Swagger i Orval do automatycznego generowania bezpiecznych hooków React Query dla Next.js.
  • Lekcje z wdrożenia Next.js SaaS i tworzenia CLI do budowy — Wyjaśnia podstawowe decyzje dotyczące wyboru technologii, autoryzacji, modelu wielu użytkowników, rozliczeń i zarządzania stanem, a także tworzenia CLI ułatwiającego konfigurację projektów Next.js.