
Як транслювати звіт у реальному часі без мерехтіння
Коли зустріч завершується, підсумок 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); а за всією дугою — анатомію доведення софту до досконалості.