Создание интерфейсов пользовательского интерфейса многоэтапных ИИ-агентов с помощью Next.js и AI SDK
Интерфейс пользователя для ИИ-агента в Next.js с использованием SDK ИИ: инструменты с типизацией, многократные циклы и компоненты генеративного интерфейса в реальном времени, отображаемые по мере работы агента, на TypeScript.
Большинство учебных материалов по ИИ-системам останавливаются именно на том этапе, когда начинают возникать реальные проблемы. Вы пишете небольшой скрипт, отправляете запрос модели и смотрите, как в терминале выводятся токены — и это уже кажется прогрессом.
Настоящие трудности появляются, когда вы пытаетесь превратить это в реальный продукт.
Тогда становится ясно, что у бизнес-пользователей нет никакого интереса к простому блоку текста в формате Markdown. Они ожидают интерактивных таблиц данных, шага подтверждения перед выполнением любых деструктивных операций, а также плавного потока вывода, который не заставляет страницу прыгать.
На самом деле им нужна модель, способная последовательно координировать несколько вызовов инструментов, при этом интерфейс должен отображать правильно сформированные компоненты React на каждом этапе этого процесса.
Проектирование интерфейса для агента подразумевает рассмотрение модели как активного движка выполнения, а не простого генератора текста. Ниже приведена схема создания профессионального интерфейса агента в Next.js с использованием Vercel AI SDK — такого, который поддерживает вызов инструментов в несколько шагов, строго типизированные данные и динамически генерируемые элементы интерфейса по мере поступления ответов.
Архитектура: за пределами простых потоков текста
Надежная система агентов сильно отличается от простого чат-бота:
[ 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)
- Транспорт с состоянием: клиент отправляет ввод пользователя обработчику маршрута, работающему на среде Edge или Node.
- Выполнение инструментов на стороне сервера: модель определяет, имеется ли у нее достаточно информации для ответа, или же ей необходимо вызвать заранее определенный инструмент, проверенный с помощью шаблона Zod, для получения внешних данных.
1. Определение API агента с использованием типизированных инструментов
Начните с создания маршрута API с помощью App Router. Цель — агент, способный получать актуальную статистику счетов и выполнять транзакционные действия, такие как генерация счетов-фактур.
Установите необходимые пакеты:
npm install ai @ai-sdk/openai @ai-sdk/react zod
Затем определите обработчик маршрута в файле 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();
}
Почему важен параметр maxSteps
Старые подходы к многократному рассуждению агента основывались на громоздких циклах while, ручных изменениях массива сообщений и отдельных запросах к LLM для каждого шага.
Установка параметра maxSteps: 5 позволяет SDK взять на себя этот рекурсивный цикл обработки инструментов на сервере, передавая каждое промежуточное состояние непосредственно клиенту по мере его формирования.
2. Создание клиента: потоковая передача типизированных компонентов
На фронтенде современные интерфейсы агентов разделяют каждый поступающий ответ на части, вместо того чтобы рассматривать его как единый текстовый блок. Один ответ может содержать фрагменты обычного текста, вызовы инструментов, ожидающие обработки, и результаты, уже полученные после выполнения.
Настройте контейнер чата в файле 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. Отрисовка генеративных компонентов интерфейса
Вместо передачи необработанного JSON вы напрямую связываете результаты схемы с компонентами отображения.
Вот файл 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>
);
}
А вот файл 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. Опыт работы в продакшене
Перенос интерфейсов вроде этого с локального хоста на живую платформу часто быстро выявляет крайние случаи. Имейте в виду следующие меры предосторожности.
1. Ограничение времени ожидания сервера на Vercel
Циклы агента, объединяющие несколько вызовов инструментов, могут легко превысить стандартное время ожидания для безсерверных функций (обычно 10–15 секунд). Обязательно укажите явное значение продолжительности в маршруте App Router:
export const maxDuration = 60; // Set according to your deployment plan limits
2. Идемпотентность выполнения инструментов
Когда модель неправильно интерпретирует собственный результат или перезапускает какой-либо шаг из-за нечетких инструкций, она может вызывать инструмент мутации более одного раза. Любой инструмент, выполняющий операции записи, такой как тот, который требует оплаты или создает новую запись в базе данных, должен использовать ключ идемпотентности, предоставленный либо непосредственно в запросе, либо полученный из идентификатора сессии.
3. Подтверждение человеком
Агенту никогда не следует разрешать автономно выполнять необратимые операции, такие как удаление записей, отправка реальных электронных писем или перевод денег.
- Агент должен создавать проект состояния, помеченный чем-то вроде
status: 'pending_approval'. - Показывать интерактивную карточку с кнопкой ручного подтверждения.
- Требовать от пользователя явного одобрения и запуска действия с помощью стандартной функции Next.js Server Action.
Новый стандарт для интерфейсов ИИ
Простые поля для дополнения текста в чате становятся устаревшими. Современные пользователи не ищут абстрактного собеседника; им нужны инструменты, которые снижают трудности в рабочем процессе, оставляя их полностью в контроле.
Сочетая потоковую передачу данных через Next.js App Router с типизированной средой выполнения инструментов AI SDK, вы превращаете языковые модели в надежные функциональные элементы, а не в непредсказуемые генераторы текста.
В результате получается система, которая сочетает предсказуемость строго типизированных компонентов TypeScript с гибким мышлением фундаментальных моделей, что именно необходимо корпоративному программному обеспечению.