
Jak transmitować raport na żywo bez migotania
Gdy kończysz spotkanie, podsumowanie GeekBye teraz wypełnia się na żywo, zamiast zmuszać cię do wpatrywania się w kręcący się wskaźnik. Sprawienie, by interfejs strumieniowy sprawiał wrażenie spokojnego, a nie nerwowego, wymagało rozwiązania migotania dwukrotnie — raz dla pól strukturalnych, raz dla markdown — a drugą naprawą był renderer, który już mieliśmy.
Przed tym wydaniem zakończenie spotkania w GeekBye oznaczało wpatrywanie się w kręcący się wskaźnik. Aplikacja wysyłała całą twoją transkrypcję do analizy, czekała na całe podsumowanie — ocenę, kluczowe punkty, zadania do wykonania, wszystko — a potem zrzucała to na ekran naraz. Działało, ale sprawiało wrażenie powolnego, bo wpatrywałeś się w nic, gdy pracowało.
GeekBye v1.6.13–v1.6.15 zmienił to w raport, który wpływa na żywo, pole po polu, w miarę jak jest generowany. Ciekawa część to nie strumieniowanie — to wszystko, co musieliśmy zrobić, by strumieniowanie nie wyglądało nerwowo. Spokojne strumieniowanie i toporne strumieniowanie to ta sama funkcja o bardzo różnym wykonaniu.
Migotanie, część pierwsza: nie pozwól, by każdy kawałek szarpał Reactem
Podsumowanie to nie jeden blok — to pola strukturalne (ogólna ocena, kluczowe punkty, zadania do wykonania i tak dalej), które backend emituje pojedynczo przez server-sent events. Naiwny sposób renderowania tego to aktualizacja stanu Reacta przy każdym zdarzeniu, gdy nadchodzi.
Zrób tak, a dostaniesz migotanie. Każde nadchodzące pole wyzwala własny ponowny render; komponenty pokazujące „puste" montują się, potem odmontowują, potem znów montują jako „wypełnione"; układ drga przy każdym pakiecie. Raport składa się sam na twoich oczach w najgorszy możliwy sposób — widocznie, nerwowo.
Naprawa to dwuczęściowa dyscyplina. Po pierwsze, akumuluj nadchodzące pola w ref — mutowalnym uchwycie, który nie wyzwala renderów — i publikuj do stanu Reacta raz na kawałek ze świeżą kopią, tak by drzewo aktualizowało się z rozmysłem, a nie przy każdym mikro-zdarzeniu. Po drugie, renderuj jedno stabilne drzewo komponentów przez cały czas, czy to podczas strumieniowania, czy po zakończeniu, z placeholderami w miejscu dla pól, które jeszcze nie dotarły:
// 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>
Komponent pokazujący ocenę nigdy się nie odmontowuje — po prostu pokazuje …, dopóki liczba nie wyląduje, a potem liczbę. Nic nie drga. A w pełni złożony obiekt na końcu i tak trafia do lokalnej bazy danych, więc strumieniowany interfejs i trwałe przechowywanie współistnieją bez walki.
Migotanie, część druga: renderer, który już mieliśmy
Drugie migotanie jest subtelniejsze i żyje w samym markdown. Raport renderuje bogaty markdown — nagłówki, pogrubienia, listy, bloki kodu. Ale markdown nadchodzący w trakcie strumienia jest, w każdej danej chwili, niedokończony. Lista z jednym punktem jak dotąd. Blok kodu, który został otwarty, ale nie zamknięty. Znacznik pogrubienia bez jeszcze pary.
Konwencjonalny renderer markdown re-parsuje cały ciąg przy każdym tokenie, a ten niedokończony stan parsuje się za każdym razem na coś innego — więc lista miga, blok kodu błyska otwarciem i zamknięciem, układ skacze, gdy tokeny się kończą. To to samo nerwowe składanie co wcześniej, jedną warstwę niżej.
Naprawa była niemal krępująco dostępna: już używaliśmy renderera markdown świadomego strumieniowania do czatu AI na żywo. Renderera zbudowanego, by tolerować niedokończone tokeny — by renderować niedokończony markdown stabilnie i osadzić go dopiero, gdy tokeny się dopełnią — zamiast re-parsować od zera za każdym razem. Raport potrzebował po prostu użyć tego samego. Rozwiązaliśmy dokładnie ten problem dla czatu miesiące wcześniej; „naprawą" dla raportu było uświadomienie sobie, że już mamy narzędzie, i skierowanie go na drugą powierzchnię. Ponowne użycie pobiło przebudowę.
Bonus: kopiowanie sformatowane to problem formatu schowka
Gdy raport już dobrze wyglądał, ludzie chcieli go wkleić — do dokumentu, do maila — i zachować formatowanie oraz zrzuty ekranu. Instynkt to zserializować raport do ciągu markdown i mieć nadzieję, że cel go wyrenderuje. Zwykle tego nie robi.
Prawdziwa odpowiedź jest taka, że schowek trzyma wiele reprezentacji naraz. Więc zapisujemy dwie: wersję HTML z nietkniętym formatowaniem i zrzutami ekranu osadzonymi jako obrazy inline oraz tekstowy fallback dla celów, które nie przyjmują HTML. Wklej do edytora sformatowanego, a dostaniesz sformatowany raport z obrazkami; wklej do zwykłego pola tekstowego, a dostaniesz czysty tekst. „Kopiowanie sformatowane" nigdy nie było problemem serializacji — to był problem wybierz-właściwy-format-schowka.
To samo wydanie dało też raportowi etykiety Ja / Oni w stylu czatu z dwoma mówcami wyrównanymi do przeciwnych stron, tak by transkrypcja czytała się jak rozmowa, którą była, i przeniosło twoje własne zrzuty ekranu na ich własną stronę, by pasowały. (Jedno wydanie w tym oknie, v1.6.14, było czystą przebudową — żadnej historii, i uczciwie jest to powiedzieć.)
Trzy rzeczy, których nauczył nas strumieniowany interfejs
- Buforuj strumieniowane pola, publikuj raz na kawałek. Pozwolić każdemu server-sent event napędzać własną aktualizację stanu to właśnie migotanie. Akumulator w ref plus pojedyncza publikacja na kawałek, do drzewa, które nigdy się nie odmontowuje, zmienia nerwowe składanie w spokojne wypełnianie.
- Cokolwiek się strumieniuje, potrzebuje renderera świadomego strumieniowania. Renderer re-parsujący cały ciąg przy każdym tokenie będzie migać na niedokończonym markdown. Użyj takiego zbudowanego dla niekompletnego wejścia — a jeśli masz już jeden do innej powierzchni, użyj go ponownie, zanim zbudujesz drugi.
- Kopiowanie sformatowane dotyczy formatów schowka, nie serializacji. Zapisz
text/htmlz osadzonymi obrazami oraz fallbacktext/plain. Schowek zaprojektowano, by nieść oba; korzystaj z tego.
To piąty rozdział opowieści o niezawodności i dopracowaniu, która staje się GeekBye v2. Po poprzedni rozdział zobacz błąd słuchawek dousznych (v1.6.12); po miejsce, gdzie ten sam pomysł server-sent events stał się później całym transportem awaryjnym, transkrypcja na żywo, gdy firewall blokuje WebSockety (v2.0.8); a po cały łuk, anatomię dostarczania oprogramowania do perfekcji.