[bug][ai-chat][performance] Чат виснет во время «размышления» после Read page: свёрнутый ReasoningBlock ре-парсит весь растущий markdown на каждый дельта-апдейт (O(n²)) #302
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Симптом
После того как агент читает страницу (
Read page) и переходит к «размышлению» (блок «Thinking · N tokens»), интерфейс AI-чата начинает тормозить и полностью зависает. Наблюдается на длинных reasoning-стримах (в репро — блок на ~8500 токенов). Main-thread насыщается, окно чата перестаёт откликаться, дельты перестают отрисовываться.Воспроизведение
Read page).Корневая причина
Файл:
apps/client/src/features/ai-chat/components/reasoning-block.tsxМеханизм:
useChatтроттлит ре-рендеры до ~20 Гц (STREAM_THROTTLE_MS = 50, см.chat-thread.tsx:42), но на каждом таком ре-рендереtrimmed— новая (более длинная) строка, поэтомуuseMemoпересчитывается.collapseBlankLines+renderChatMarkdown(marked+DOMPurify) по всему растущему reasoning-тексту. Для ~8500 токенов это ~34 КБ, парсимых ~20 раз/сек по мере роста → классический O(n²)-шторм, который забивает main-thread и морозит UI.const [open, setOpen] = useState(false)), то есть весь этот тяжёлый парсинг идёт для невидимого контента. Пользователь его даже не видит.Что здесь не виновато (проверено):
ToolCallCardне рендерит большой вывод страницы — только лейбл и цитаты, поэтому объём прочитанной страницы в рендер не попадает.MarkdownPartвmessage-item.tsx) во время фазы «размышления» ещё пуст — парсить нечего.messageSignatureдешёвая (.lengthвместо сериализации,outputпроверяется через!== undefined).estimateTokens(text)— O(1) (text.length / 4).То есть на фазе thinking единственный доминирующий расход — полный ре-парсинг markdown растущего reasoning в свёрнутом (невидимом) блоке.
Исторический контекст: троттлинг и мемоизация уже добавлялись ровно против «quadratic CPU storm that pins the main thread and freezes the UI» (см. комментарий у
STREAM_THROTTLE_MS), но для ответа. Reasoning-блок остался с полным ре-парсингом на каждый апдейт — да ещё и в свёрнутом состоянии.Предлагаемый фикс
Парсить markdown reasoning только когда блок реально раскрыт (
open). Это убирает весь скрытый O(n²)-парсинг во время «размышления» — то есть ровно тот сценарий, что вызывает зависание. При раскрытии блок один раз распарсит текущий текст (мгновенная операция по клику пользователя), а при дальнейшем стриминге в открытом виде — обычный append-рендер, как у ответа.В
reasoning-block.tsx:Когда блок свёрнут,
html === "", и внутри закрытого<Collapse in={open}>рендерится дешёвый raw-text fallback (<Text style={{ whiteSpace: "pre-wrap" }}>{trimmed}</Text>) — он скрыт (высота 0) и не требует парсинга markdown. Видимого регресса нет: пользователь всегда видит уже распарсенный markdown только после раскрытия. Заголовок «Thinking · N tokens» и его live-счётчик (estimateTokens) не затрагиваются.Второстепенное (по желанию)
Если блок раскрыт во время стриминга очень длинного reasoning, ре-парсинг снова станет O(n²) (как у стримящегося ответа) — но это осознанное действие пользователя над видимым контентом и случай редкий. Основной фикс закрывает поведение по умолчанию (свёрнуто), которое и вызывает зависание. При желании позже можно добавить дебаунс парсинга для открытого стримящегося блока.
Затронутые файлы
apps/client/src/features/ai-chat/components/reasoning-block.tsx— гейтинг парсинга поopen(правкаuseMemo).apps/client/src/features/ai-chat/components/reasoning-block.test.tsx— существующий тест «renders the reasoning body (markdown or raw-text fallback)» остаётся зелёным (он уже допускает raw-text fallback). Желательно добавить тест: свёрнутый блок не вызываетrenderChatMarkdown(мок), а после раскрытия — вызывает.Критерии готовности (DoD)
nx run client:test(или соответствующий таск) зелёный.Диагностика проведена по коду ветки
develop. Комментарии в коде — на английском по конвенции репозитория.