Main/React & Next.js/Blueprints/Next.js & Fullstack/Стриминговый чат-интерфейс с ИИ-ассистентом на Vercel AI SDK и Markdown рендерингом
Back to «Next.js & Fullstack» cards
Next.js & Fullstack(ТЗ 10 из 10)
ТЗ: Стриминговый чат-интерфейс с ИИ-ассистентом на Vercel AI SDK и Markdown рендерингом
Next.js & Fullstack1 шагов • +5 XP

ТЗ: Стриминговый чат-интерфейс с ИИ-ассистентом на Vercel AI SDK и Markdown рендерингом

Инженерная спецификация разработки высокоскоростного диалогового AI интерфейса: посимвольный стриминг через ReadableStream, подсветка синтаксиса кода с кнопкой копирования, безопасная гидратация и отмена генерации.

~10 ч работыNext.js 16 App RouterReact 19ReadableStream / SSEAbortControllerCustom Markdown ParserTailwind CSS v4
+5 XP за шаг
Выполнено 0 из 1 шагов (0%)
Трудозатраты~10 рабочих часов
Фриланс вилка~$450 – $750 (€400 – €700)
Оценка веб-студии~$950 – $1,600 (€900 – €1500)

ТЗ заказчика (исходный бриф):

Никаких пауз на размышления по 10 секунд. Стриминг должен начинаться моментально, блоки кода должны аккуратно форматироваться, а кнопка "Остановить" должна мгновенно прерывать сетевой сокет.
Ключевые требования:
  • •Потоковая передача чанков текста через нативный ReadableStream
  • •Управление отменой генерации через AbortController
  • •Рендеринг блоков кода с копированием в буфер обмена
  • •Защита от hydration mismatch при рендеринге динамического контента
Ограничения проекта:
  • •Не использовать тяжелые сторонние рантайм-парсеры, весящие более 100 КБ
  • •Стриминг должен работать в Edge и Serverless средах
Критерии приёмки (KPI):
  • Time to First Token (TTFT) < 600ms
  • Smooth 60 FPS рендеринг текста во время стрима

Пошаговое практическое руководство по ТЗ

Подробный разбор архитектуры, код реализации и советы эксперта по каждому этапу:

1
Этап 1 из 1

Реализация стримингового Route Handler с AbortSignal

~60 минСредний

Практическое руководство:

Создать app/api/chat/route.ts, принимающий массив сообщений и транслирующий поток токенов LLM в ReadableStream.

Важные технические нюансы и подводные камни:

Убедитесь, что заголовки содержат Content-Type: text/plain; charset=utf-8 или text/event-stream.

Архитектурная база (Почему выбрано такое решение):

Трансляция SSE в сырой текстовый стрим упрощает обработку на клиенте и обеспечивает минимальные накладные расходы по памяти.

Совет эксперта (Best Practice):

Проброс req.signal в fetch автоматически прерывает обращение к LLM, если пользователь нажал кнопку отмены или закрыл вкладку.

Ожидаемый артефакт этапа:

Стриминговый эндпоинт app/api/chat/route.ts с поддержкой AbortSignal.

TYPESCRIPT
// app/api/chat/route.ts
import { NextRequest } from 'next/server'

export const dynamic = 'force-dynamic'

export async function POST(req: NextRequest) {
  const { messages } = await req.json()
  const apiKey = process.env.GEMINI_API_KEY

  if (!apiKey) {
    return new Response('API key not configured', { status: 500 })
  }

  // Запрос к LLM со стримингом
  const response = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=${apiKey}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: messages.map((m: { role: string; content: string }) => ({
          role: m.role === 'user' ? 'user' : 'model',
          parts: [{ text: m.content }]
        })),
        generationConfig: {
          thinkingConfig: { thinkingBudget: 0 },
          maxOutputTokens: 2048
        }
      }),
      signal: req.signal // Проброс отмены клиента
    }
  )

  const reader = response.body?.getReader()
  const encoder = new TextEncoder()
  const decoder = new TextDecoder()

  const stream = new ReadableStream({
    async start(controller) {
      if (!reader) {
        controller.close()
        return
      }

      let buffer = ''
      try {
        while (true) {
          const { done, value } = await reader.read()
          if (done) break

          buffer += decoder.decode(value, { stream: true })
          const lines = buffer.split('\n')
          buffer = lines.pop() || ''

          for (const line of lines) {
            const trimmed = line.trim()
            if (!trimmed.startsWith('data:')) continue
            const dataStr = trimmed.slice(5).trim()
            if (dataStr === '[DONE]') continue

            try {
              const parsed = JSON.parse(dataStr)
              const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text
              if (text) controller.enqueue(encoder.encode(text))
            } catch {}
          }
        }
      } catch (err) {
        controller.error(err)
      } finally {
        controller.close()
      }
    }
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' }
  })
}

Типичные грабли на этом ТЗ

1 антипаттерна

Что сдаём клиенту (Deliverables)

Чек-лист сдачи
Потоковый роут-хэндлер с минимальным TTFB
Клиентский чат с поддержкой AbortController
Форматирование Markdown и блоков кода