
Como transmitir um relatório ao vivo sem a cintilação
Quando a sua reunião termina, o resumo do GeekBye agora se preenche ao vivo em vez de o obrigar a olhar para um spinner. Fazer com que a interface em streaming parecesse calma em vez de trêmula exigiu resolver a cintilação duas vezes — uma para os campos estruturados, outra para o markdown — e a segunda correção foi um renderizador que já tínhamos.
Antes deste lançamento, terminar uma reunião do GeekBye significava olhar para um spinner. A app enviava toda a sua transcrição para análise, esperava pelo resumo inteiro — pontuação, pontos-chave, ações a tomar, tudo — e depois despejava-o no ecrã de uma só vez. Funcional, mas parecia lento, porque você ficava a olhar para o nada enquanto ela trabalhava.
O GeekBye v1.6.13–v1.6.15 transformou isso num relatório que se transmite ao vivo, campo a campo, à medida que é gerado. A parte interessante não é o streaming — é tudo o que tivemos de fazer para impedir que o streaming parecesse trêmulo. O streaming calmo e o streaming brusco são a mesma funcionalidade com execuções muito diferentes.
Cintilação, parte um: não deixe cada fragmento sacudir o React
O resumo não é um único bloco — são campos estruturados (uma pontuação geral, pontos-chave, ações a tomar, e assim por diante) que o backend emite um a um sobre server-sent events. A forma ingénua de renderizar isso é atualizar o estado do React a cada evento assim que chega.
Faça isso, e obtém cintilação. Cada campo que chega dispara a sua própria re-renderização; componentes que mostravam "vazio" montam, depois desmontam, depois voltam a montar como "carregado"; o layout treme a cada pacote. O relatório monta-se sozinho à sua frente da pior maneira possível — visível, nervosamente.
A correção é uma disciplina em duas partes. Primeiro, acumule os campos que chegam num ref — um contentor mutável que não dispara renderizações — e publique para o estado do React uma só vez por fragmento com uma cópia nova, para que a árvore se atualize de forma deliberada em vez de a cada micro-evento. Segundo, renderize uma única árvore de componentes estável o tempo todo, esteja em streaming ou terminada, com marcadores em linha para os campos que ainda não chegaram:
// 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>
O componente que mostra a pontuação nunca é desmontado — apenas mostra … até o número aterrar, e depois o número. Nada treme. E o objeto totalmente montado continua a ser guardado em cache na base de dados local no fim, por isso a interface em streaming e o armazenamento durável coexistem sem brigar.
Cintilação, parte dois: o renderizador que já tínhamos
A segunda cintilação é mais subtil e vive no próprio markdown. O relatório renderiza markdown formatado — títulos, negrito, listas, blocos de código. Mas o markdown que chega a meio do fluxo está, em qualquer instante, meio terminado. Uma lista com um só marcador por agora. Um bloco de código que foi aberto mas não fechado. Um marcador de negrito ainda sem par.
Um renderizador de markdown convencional reanalisa toda a string a cada token, e esse estado meio terminado analisa-se como algo diferente de cada vez — por isso a lista cintila, o bloco de código pisca abrindo e fechando, o layout salta à medida que os tokens se completam. É a mesma montagem nervosa de antes, uma camada abaixo.
A correção estava quase embaraçosamente à mão: já estávamos a usar um renderizador de markdown ciente do streaming para o chat de IA ao vivo. Um renderizador construído para tolerar tokens incompletos — para renderizar markdown meio terminado de forma estável e só o assentar quando os tokens se completam — em vez de reanalisar do zero a cada vez. O relatório só precisava de usar o mesmo. Tínhamos resolvido exatamente este problema para o chat meses antes; a "correção" para o relatório foi reconhecer que já tínhamos a ferramenta e apontá-la para uma segunda superfície. Reutilizar venceu reconstruir.
O bónus: a cópia formatada é um problema de formato da área de transferência
Assim que o relatório ficou com bom aspeto, as pessoas quiseram colá-lo — num documento, num e-mail — e manter a formatação e as capturas de ecrã. O instinto é serializar o relatório numa string de markdown e torcer para que o destino a renderize. Normalmente não renderiza.
A verdadeira resposta é que a área de transferência guarda várias representações ao mesmo tempo. Por isso escrevemos duas: uma versão HTML com a formatação intacta e as capturas incorporadas como imagens em linha, e uma alternativa em texto simples para destinos que não aceitam HTML. Cole num editor formatado e obtém o relatório formatado com imagens; cole numa caixa de texto simples e obtém texto limpo. A "cópia formatada" nunca foi um problema de serialização — foi um problema de escolher-o-formato-certo-da-área-de-transferência.
O mesmo lançamento também deu ao relatório rótulos ao estilo do chat Eu / Eles com os dois interlocutores alinhados em lados opostos, para que uma transcrição se leia como a conversa que foi, e moveu as suas próprias capturas de ecrã para o seu próprio lado para combinar. (Um lançamento nesta janela, o v1.6.14, foi uma pura recompilação — não há história aí, e é honesto dizê-lo.)
Três coisas que a interface em streaming nos ensinou
- Guarde em buffer os campos transmitidos, publique uma vez por fragmento. Deixar cada server-sent event conduzir a sua própria atualização de estado é a cintilação. Um acumulador com ref mais uma única publicação por fragmento, numa árvore que nunca é desmontada, transforma a montagem nervosa num preenchimento calmo.
- Tudo o que transmite precisa de um renderizador ciente do streaming. Um renderizador que reanalisa toda a string por token vai cintilar sobre markdown meio terminado. Use um construído para entradas incompletas — e se já tem um para outra superfície, reutilize-o antes de construir um segundo.
- A cópia formatada é sobre formatos da área de transferência, não serialização. Escreva
text/htmlcom imagens incorporadas e uma alternativatext/plain. A área de transferência foi concebida para transportar ambos; use-a.
Este é o quinto capítulo da história de fiabilidade e polimento que se torna o GeekBye v2. Para o capítulo anterior, veja o bug dos auriculares (v1.6.12); para o ponto onde a mesma ideia de server-sent events se tornou depois todo um transporte de reserva, transcrição ao vivo quando a firewall bloqueia WebSockets (v2.0.8); e para todo o arco, a anatomia de lançar software até à perfeição.