
Как транслировать отчёт в реальном времени без мерцания
Когда встреча заканчивается, сводка GeekBye теперь заполняется вживую, а не заставляет вас смотреть на крутящийся индикатор. Чтобы потоковый интерфейс ощущался спокойным, а не дёрганым, пришлось решать мерцание дважды — один раз для структурных полей, один раз для markdown — и вторым решением стал рендерер, который у нас уже был.
До этого релиза завершение встречи в GeekBye означало смотреть на крутящийся индикатор. Приложение отправляло всю вашу транскрипцию на анализ, ждало всю сводку целиком — оценку, ключевые моменты, задачи, всё — и потом вываливало это на экран разом. Работало, но ощущалось медленным, потому что вы смотрели в пустоту, пока оно работало.
GeekBye v1.6.13–v1.6.15 превратил это в отчёт, который вливается вживую, поле за полем, по мере генерации. Интересна не сама потоковая передача — а всё, что нам пришлось сделать, чтобы стриминг не выглядел дёргано. Спокойный стриминг и топорный стриминг — это одна и та же функция с очень разным исполнением.
Мерцание, часть первая: не давай каждой порции трясти React
Сводка — это не один блок, а структурные поля (общая оценка, ключевые моменты, задачи и так далее), которые бэкенд выдаёт по одному через server-sent events. Наивный способ это отрендерить — обновлять состояние React на каждом событии по мере поступления.
Сделай так — и получишь мерцание. Каждое приходящее поле запускает собственный повторный рендер; компоненты, показывавшие «пусто», монтируются, потом размонтируются, потом монтируются снова как «заполнено»; вёрстка дёргается на каждом пакете. Отчёт собирает себя сам у вас на глазах наихудшим возможным образом — заметно, нервно.
Исправление — это двухчастная дисциплина. Во-первых, накапливай приходящие поля в ref — изменяемом держателе, который не запускает рендеры, — и публикуй в состояние React раз на порцию со свежей копией, чтобы дерево обновлялось намеренно, а не на каждое микрособытие. Во-вторых, рендери одно стабильное дерево компонентов всё время, при стриминге или после завершения, с плейсхолдерами на месте для полей, которые ещё не пришли:
// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })
// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>
Компонент, показывающий оценку, никогда не размонтируется — он просто показывает …, пока число не приземлится, а затем число. Ничего не дёргается. А полностью собранный объект в конце всё равно кешируется в локальную базу данных, так что потоковый интерфейс и надёжное хранилище сосуществуют без борьбы.
Мерцание, часть вторая: рендерер, который у нас уже был
Второе мерцание тоньше и живёт в самом markdown. Отчёт рендерит богатый markdown — заголовки, жирный текст, списки, блоки кода. Но markdown, приходящий в середине потока, в любой момент недописан. Список с одним пунктом пока что. Блок кода, который открыли, но не закрыли. Маркер жирного без пары.
Обычный рендерер markdown повторно парсит всю строку на каждом токене, и это недописанное состояние каждый раз парсится во что-то иное — так что список мерцает, блок кода вспыхивает открытием и закрытием, вёрстка прыгает, пока токены дописываются. Это то же нервное собирание, что и раньше, слоем ниже.
Исправление было почти неловко доступным: мы уже использовали рендерер markdown с учётом стриминга для живого чата с ИИ. Рендерер, построенный терпеть неполные токены — рендерить недописанный markdown стабильно и устаканивать его лишь по мере дописывания токенов — вместо того чтобы каждый раз парсить с нуля. Отчёту нужно было просто использовать тот же самый. Мы решили ровно эту задачу для чата месяцами раньше; «исправлением» для отчёта было осознание, что инструмент у нас уже есть, и направление его на вторую поверхность. Повторное использование побило перестройку.
Бонус: форматированное копирование — это задача формата буфера обмена
Когда отчёт стал хорошо выглядеть, люди захотели его вставлять — в документ, в письмо — и сохранять форматирование и скриншоты. Инстинкт — сериализовать отчёт в строку markdown и надеяться, что цель её отрендерит. Обычно нет.
Настоящий ответ в том, что буфер обмена держит несколько представлений сразу. Так что мы записываем два: HTML-версию с сохранённым форматированием и скриншотами, вложенными как inline-картинки, и текстовый запасной вариант для целей, которые не принимают HTML. Вставь в форматированный редактор — получишь отформатированный отчёт с картинками; вставь в простое текстовое поле — получишь чистый текст. «Форматированное копирование» никогда не было задачей сериализации — это была задача выбери-правильный-формат-буфера-обмена.
Тот же релиз ещё дал отчёту метки Я / Они в стиле чата с двумя говорящими, выровненными по противоположным сторонам, чтобы транскрипция читалась как разговор, которым и была, и перенёс ваши собственные скриншоты на их сторону, чтобы совпадало. (Один релиз в этом окне, v1.6.14, был чистой пересборкой — никакой истории тут нет, и честно так и сказать.)
Три вещи, которым нас научил потоковый интерфейс
- Буферизуй потоковые поля, публикуй раз на порцию. Дать каждому server-sent event гнать собственное обновление состояния — это и есть мерцание. Аккумулятор в ref плюс единая публикация на порцию, в дерево, которое никогда не размонтируется, превращает нервное собирание в спокойное заполнение.
- Всё, что стримится, нуждается в рендерере с учётом стриминга. Рендерер, повторно парсящий всю строку на каждом токене, будет мерцать на недописанном markdown. Используй построенный под неполный ввод — а если у тебя уже есть один для другой поверхности, переиспользуй его, прежде чем строить второй.
- Форматированное копирование про форматы буфера обмена, а не про сериализацию. Запиши
text/htmlс вложенными картинками и запаснойtext/plain. Буфер обмена спроектирован нести оба; пользуйся этим.
Это пятая глава истории про надёжность и отделку, которая становится GeekBye v2. За предыдущей главой смотри баг с наушниками-вкладышами (v1.6.12); за тем, где та же идея server-sent events позже стала целым запасным транспортом, транскрипция вживую, когда файрвол блокирует WebSockets (v2.0.8); а за всей дугой — анатомию доведения софта до совершенства.