Головна / Статті

Створення користувацьких інтерфейсів багатокрокових AI-агентів за допомогою Next.js та AI SDK

Інтерфейс користувача для агента ШІ у Next.js з SDK ШІ: інструменти з типуванням, багатокрокові цикли та компоненти інтерфейсу, створені за допомогою потокових технологій, які відображаються під час роботи агента, написані на TypeScript.

8 хв читання1868 слів

Більшість навчальних матеріалів про системи ШІ зупиняються саме на тому етапі, коли справи починають набувати значення. Ви пишете невеликий скрипт, надсилаєте запит моделі та спостерігаєте, як у терміналі виводяться токени — і це вже здається прогресом.

Справжні труднощі виникають, коли ви намагаєтесь перетворити це на справжній продукт.

Саме тоді стає зрозуміло, що бізнес-користувач не зацікавлений у сирому блоку тексту формату 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, для отримання зовнішніх даних.
  • Багатокрокові цикли агента: як тільки інструмент завершує виконання на сервері, його результат автоматично передається назад у модель — згідно з обмеженнями кроків, налаштованими в SDK — поки вона не створить кінцеву відповідь мовою природи.
  • Типізований генеративний інтерфейс: замість відображення сирого JSON клієнт виділяє типізовані фрагменти інструментів з потоку та створює для них спеціалізовані, інтерактивні компоненти React.
  • 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. Відображення генеративних компонентів UI

    Замість того, щоб виводити сирий 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. Досвід роботи в продакшені

    Перенесення такого інтерфейсу з localhost у живе розгортання часто швидко виявляє нетипові ситуації. Пам’ятайте про наступні заходи безпеки.

    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 із гнучким мисленням фундаментальних моделей, що саме є балансом, необхідним для корпоративного програмного забезпечення.