Как я заставил чат-виджет портфолио стримить, как ChatGPT

Собираю чат-виджет, подключаю его к OpenRouter с вызовом инструментов, а затем перехожу от обычного ответа к Server-Sent Events — на этот раз владея бэкендом, а не только фронтендом.

Терминал стримит Server-Sent Events — кадры data delta печатают ответ слово за словом

В прошлом посте я дал своему портфолио MCP-сервер и в конце встроил небольшой чат-виджет, чтобы его можно было пощупать. Этот виджет не вываливает готовый ответ. Он печатает ответ слово за словом — так же, как ChatGPT.

Этот пост о том, как виджет собирался — фронтенд-пузырь, подключение к настоящему LLM через OpenRouter, и та часть, которая мне была реально интересна: заставить его стримить.

Половину этого я уже делал

Честный момент. Что-то подобное я уже собирал раньше — небольшого встроенного ассистента, который делал сводку по дашборду для пользователя. Но я всегда владел только фронтенд-половиной. Токены приходили потоком, я добавлял их в пузырь, и выглядело это отлично. А бэкенд, который этот поток производил — вызовы модели, оркестрация инструментов, сами кадры data: — был чужой коробкой, которую я просто потреблял.

То есть рисовать поток я умел. А вот отдавать его — ни разу.

В этот раз я хотел оба конца. Свой эндпоинт, свой цикл инструментов, свой SSE. На своём крошечном портфолио, где ломать что-то в проде ничего не стоит.

Часть 1 — виджет

Фронтенд — простая часть и в основном ничем не примечательная: плавающий пузырь, список сообщений, поле ввода. Стоит отметить две детали.

Markdown. LLM отвечает в Markdown, поэтому виджет парсит его через marked и санитизирует результат через DOMPurify до того, как тот попадёт в innerHTML. Санитизация не опциональна — вы кладёте вывод модели прямо в DOM.

Гоча в Astro, которая стоила мне часа. Сначала я написал клиентскую логику внутри блока <script define:vars={{ apiBase }}>, чтобы передать туда URL сервера. Такой скрипт рендерится инлайн, а инлайновые скрипты не бандлятся — поэтому import библиотеки marked просто не резолвился. Решение — обычный бандлящийся <script> и передача серверных значений через data--атрибут:

<div id="chat-widget" data-api-base={apiBase}>…</div>
<script>
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const widget = document.getElementById('chat-widget')!
const apiBase = widget.dataset.apiBase
</script>

Скучно, когда знаешь. Загадочно на час, когда нет.

Часть 2 — подключаем OpenRouter

Бэкенд — одна serverless-функция на Vercel. Она берёт диалог, добавляет системный промпт и вызывает OpenRouter — который говорит на OpenAI-совместимом API, так что одна и та же форма запроса работает для разных моделей. Я использую бесплатный тариф и небольшой набор read-only инструментов, читающих из того же Git-репозитория, из которого собирается сайт:

about_me
search_projects
get_project
search_blog
get_article

Цикл — стандартный танец с вызовом инструментов: отправляешь сообщения и определения инструментов, и если модель возвращается с просьбой вызвать инструмент, ты его выполняешь, добавляешь результат и спрашиваешь снова. До нескольких итераций, потом ответ.

Первая версия была блокирующей. Один POST, ждём всё целиком, возвращаем JSON. Работало. Но LLM, которому надо подумать, вызвать инструмент, прочитать результат и затем написать три предложения, легко тратит несколько секунд — и все эти секунды виджет просто крутит спиннер. Что подводит нас к интересному.

Часть 3 — а ChatGPT правда использует SSE?

Да. Когда вы смотрите, как ChatGPT печатает, браузер держит один длинный HTTP-ответ открытым, а сервер шлёт в него маленькие куски как Server-Sent Events — поток text/event-stream, где каждое событие это строка, начинающаяся с data:. Вот и весь фокус. Никаких вебсокетов, никакого поллинга. Просто ответ, который отказывается заканчиваться.

Сравнение двух стилей ответа: блокирующий запрос ждёт и выдаёт один большой пузырь против Server-Sent Events, которые шлют маленькие кадры data delta, так что ответ печатается сам и заканчивается кадром done
Один и тот же ответ, два ощущения. Нижний кажется живым, потому что вы видите, как он рождается.

Единственная загвоздка: нативный EventSource в браузере умеет только GET и не может ставить заголовки, а мне надо POST-ить диалог. Поэтому на клиенте я не использую EventSource вообще — я беру fetch и читаю тело ответа как поток:

const res = await fetch(apiBase + '/api/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: conversation }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = 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) {
if (!line.startsWith('data:')) continue
const evt = JSON.parse(line.slice(5))
if (evt.type === 'delta') { answer += evt.text; render() }
}
}

На сервере «отправить событие» — это просто запись:

const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`)

Плюс правильные заголовки, чтобы никто по дороге не решил сбуферизировать ваш поток в один комок: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform и X-Accel-Buffering: no.

Часть, над которой пришлось подумать

Стримить обычный ответ чата — тривиально. Стримить ответ, который сначала прогоняет цикл инструментов, — нет, потому что в одном диалоге теперь два очень разных типа хода:

  • ход инструмента, где модель выдаёт куски tool_calls и никакого текста, и
  • ход ответа, где она наконец пишет текст для человека.

Если наивно форвардить каждый кусок content, вы либо не отдадите ничего (у ходов инструментов нет content), либо, хуже, начнёте стримить полусформированную мысль, которую модель потом перепишет. На проводе мне нужен только финальный ответ. Поэтому сервер держит флаг toolMode и форвардит content только когда не в середине вызова инструмента:

if (Array.isArray(delta.tool_calls)) toolMode = true
// ходы инструментов несут tool_calls, а не текст — стримим только реальный ответ
if (typeof delta.content === 'string' && !toolMode) {
send({ type: 'delta', text: delta.content })
}

Когда цикл наконец завершается без новых вызовов инструментов, он шлёт один { type: 'done' } и закрывает ответ. Вот вся схема целиком:

Виджет в браузере POST-ит сообщения в Vercel-функцию api/stream, которая крутит цикл вызовов инструментов через OpenRouter, который обращается к read-only инструментам данных портфолио; финальный ответ стримится обратно в браузер через Server-Sent Events, а обычный эндпоинт api/chat остаётся как запасной
Поток несёт только финальный ответ. Цикл инструментов тихо происходит на сервере.

Оставляю старый эндпоинт как страховку

Блокирующую версию я не удалил. Исходный /api/chat по-прежнему на месте, нетронутый, и виджет откатывается на него, если стрим падает, возвращает плохой статус или у него нет тела. Стриминг — приятный путь; скучный путь — страховочная сетка. Держать его стоит почти ничего, а значит сбойный стрим деградирует до рабочего ответа, а не до ошибки.

Мелочь, которая довела дело до вида «готово»

Даже со стримингом бывают паузы — модель задумалась, выполняется инструмент, кусок пришёл медленно. В эти паузы полунаписанный пузырь выглядит замёрзшим, будто вкладка зависла. Лечится анимированным индикатором из трёх точек, который живёт внутри пузыря и тянется за текстом весь стрим, исчезая только на done. Это несколько строк CSS, и это разница между «оно сломалось?» и «оно думает».

Что получилось

Результат — тот самый виджет внизу поста про MCP — и прямо здесь тоже. Спросите его что-нибудь о моих проектах или статьях. Он отправит ваш вопрос модели, модель решит, какие инструменты портфолио вызвать, и ответ печатается вам обратно через SSE.

Спросите об этом сайте

Спросите об этом сайте

Ничего новаторского тут нет — SSE стар, циклы инструментов исхожены вдоль и поперёк. Но я наконец собрал ту половину, которую всегда оставлял кому-то другому, и оказалось, что «магический» эффект печати у ChatGPT — это просто ответ, который ты не закрываешь.

См. также: Я дал своему портфолио MCP-сервер — бэкенд, с которым говорит этот виджет.