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.
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ł.
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
- ReAct wyjaśnione: Jak agenci AI łączą rozumowanie z działaniami w praktyce — Dowiedz się, jak framework ReAct łączy rozumowanie z wykorzystaniem narzędzi w celu napędzania agentów AI, oraz w jaki sposób różni się od modeli typu Chain-of-Thought, RL i rozumowania.
- Przekształcanie handlarzy tras w Next.js w celową warstwę BFF — Dowiedz się, co rozwiązuje wzorzec Backend for Frontend, dlaczego pojawia się ponownie w aplikacjach Next.js i jak uniknąć przekształcania handlarzy tras w rozbudowane „bóstwa” programistyczne.