---
title: "Стрімінг відповідей ШІ з Laravel до React: досвід команди There There"
url: https://laravelukraine.com/blog/striming-vidpovidei-si-z-laravel-do-react-dosvid-komandi-there-there
date: 2026-10-07
source: https://freek.dev/3092-streaming-ai-replies-from-laravel-to-react
---

# Стрімінг відповідей ШІ з Laravel до React: досвід команди There There

Фрік Ван дер Ертен (Freek Van der Herten) продовжує серію дописів про [агента та інструменти](https://freek.dev/3089-laravel-ai-sdk-in-practice-the-agent-behind-there-there), які команда Spatie побудувала на основі Laravel AI SDK для [There There](https://there-there.app) - гелпдеск-системи, що наразі розробляється. Попередній допис завершився невеликою інтригою: агент передається стрімінговому сервісу, який перетворює потік відповіді SDK на події, зрозумілі фронтенду. Саме цьому присвячено новий матеріал.

## Підхід There There

Після двох десятиліть власної підтримки клієнтів команда хотіла створити гелпдеск, де ШІ робить агентів підтримки швидшими, а не намагається їх замінити. Людина читає, думає й спрямовує, а модель на її очах у реальному часі готує чернетки та шукає потрібну інформацію. There There зараз перебуває в закритій бета-версії, а заявку на ранній доступ можна подати на [there-there.app](https://there-there.app).

## Стрімінг із Laravel до React

Найочевидніший спосіб передати відповідь LLM у браузер - дочекатися її завершення й відрендерити все одразу. Це просто, але агент здається млявим. Натомість команда хоче, щоб відповідь з'являлася слово за словом, а міркування агента (наприклад, який інструмент він викликає) озвучувалися по ходу.

Laravel AI SDK надає кожен крок розмови як типізовану подію. Сервіс ітерує цей потік і надсилає фронтенду newline-delimited JSON (NDJSON) - один рядок на подію.

Ось ядро `AgentStreamingService`:

```php
public function stream(Agent&HasTools $agent, string $message, AgentChat $chat): StreamedResponse
{
    return response()->stream(function () use ($agent, $message, $chat): Generator {
        yield from $this->streamAgent($agent, $message, $chat);
    }, 200, [
        'Content-Type' => 'application/x-ndjson',
        'Cache-Control' => 'no-cache',
    ]);
}

private function streamAgent(Agent&HasTools $agent, string $message, AgentChat $chat): Generator
{
    set_time_limit(120);

    $fullContent = '';
    $toolCalls = [];

    foreach ($agent->stream($message) as $event) {
        if ($event instanceof ToolCall) {
            yield json_encode(['type' => 'tool_call', 'tool_name' => $event->toolCall->name."\n";
        }

        if ($event instanceof TextDelta) {
            $fullContent .= $event->delta;
            yield json_encode(['type' => 'delta', 'content' => $event->delta]))."\n";
        }

        if ($event instanceof ToolResult) {
            $toolCalls[] = [
                'name' => $event->toolResult->name,
                'arguments' => $event->toolResult->arguments,
                'result' => $event->toolResult->result,
            ];
        }
    }

    $chat->messages()->create([
        'role' => MessageRole::Assistant,
        'content' => $fullContent,
        'tool_calls' => $toolCalls ?: null,
    ]);

    yield json_encode([
        'type' => 'done',
        'html' => $this->parser->buildFinalHtml($fullContent),
    ])."\n";
}
```

На що варто звернути увагу:

- `response()->stream()` приймає генератор і негайно надсилає клієнту кожен `yield` - у цьому весь фокус із серверним пушем прогресу.
- Події типізовані (`delta`, `tool_call`, `done`), тож фронтенд відрізняє фрагмент тексту від виклику інструмента.
- Готове повідомлення зберігається в базі даних в тому ж проході. Сам потік є джерелом істини, а збережена копія використовується для відновлення чату, коли користувач відкриє його наступного разу.

## Споживання потоку на фронтенді

На клієнті потік читається звичайним `fetch`. Жодних бібліотек і жодного SSE-фреймінгу. Ось хук, скорочений до суттєвого:

```js
const response = await fetch(sendUrl, {
    method: 'POST',
    headers: { ...csrfHeaders(), 'Content-Type': 'application/json' },
    body: JSON.stringify(buildBody(text, html)),
    signal: controller.signal,
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

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) {
        processLine(line);
    }
}

if (buffer.trim()) {
    processLine(buffer);
}
```

Єдине, що треба зробити правильно, - це буферизація. Один виклик `reader.read()` може повернути частковий рядок, цілий рядок або одразу кілька. Тому текст розбивається за `\n`, останній (можливо, неповний) фрагмент лишається в `buffer`, а наприкінці його також обробляють. Кожен завершений рядок - це одна подія.

## Обробка подій у стані

Диспетчеризація кожної події у стан - це короткий switch:

```js
if (event.type === 'tool_call') {
    setMessages((prev) => updateLastStreaming(prev, {
        toolStatus: formatToolName(event.tool_name),
    }));
} else if (event.type === 'delta') {
    setMessages((prev) => updateLastStreaming(prev, (last) => ({
        content: last.content + event.content,
        toolStatus: null,
    })));
} else if (event.type === 'done') {
    setMessages((prev) => updateLastStreaming(prev, {
        html: event.html,
        isStreaming: false,
    }));
}
```

Коли надходить `tool_call`, над відповіддю, що ще стрімиться, з'являється невелика позначка на кшталт «Looking up tickets». Коли починають надходити `delta`, позначку прибирають і дописують текст до вмісту повідомлення. Із подією `done` підставляється відрендерений на сервері HTML, а прапорець стрімінгу вимикається. Загалом виглядає так, ніби агент друкує просто перед вами.

## Підсумок

Існують і «важчі» способи: server-sent events, websockets, повноцінна стрімінгова бібліотека з обох боків. Для чат-інтерфейсу нічого з цього не потрібно. Генератор на сервері, NDJSON у каналі та невеликий буферний цикл на клієнті - це приблизно сотня рядків від початку до кінця, і працює це саме так, як має поводитися справжній стрімінговий LLM-чат.

Докладніше про події, які генерує SDK, можна дізнатися в [репозиторії Laravel AI SDK](https://github.com/laravel/ai). А якщо хочете спробувати There There, команда зараз у закритій бета-версії - подати заявку на ранній доступ можна на [there-there.app](https://there-there.app).
