From 9451d21de82d2e41905d0ba97e5b87cde1ae1715 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 20 Aug 2026 14:29:39 +0800 Subject: [PATCH] fix(web): keep streamed replies painting incrementally --- tests/test_static_vendor.py | 8 ++++++++ web/static/js/chat.js | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 181dc45..3479f7b 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -199,6 +199,14 @@ class StaticVendorTests(unittest.TestCase): self.assertIn('p > 1 ? "100%+"', src) self.assertIn("cache_hit_tokens", src) + def test_sse_consumer_yields_for_streaming_text_paints(self) -> None: + chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8") + + self.assertIn("renderEventsSincePaint", chat_js) + self.assertIn("renderEventsSincePaint >= 24", chat_js) + self.assertIn("await yieldToStreamPaint()", chat_js) + self.assertIn('document.visibilityState === "hidden"', chat_js) + def test_custom_task_name_is_optional_with_explicit_directory(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8") diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 9c8e703..11686dd 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2975,6 +2975,7 @@ async function consumeSseStream(url, asstCard, ctx) { const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = ""; + let renderEventsSincePaint = 0; setRunHint(ctx, "接收中…"); while (true) { const { value, done } = await reader.read(); @@ -2988,15 +2989,40 @@ async function consumeSseStream(url, asstCard, ctx) { const ev = parseSseFrame(frame); if (!ev) continue; handleSseEvent(ev, asstCard, ctx); + if ((ev.event === "text" || ev.event === "reasoning") && ev.data && ev.data.delta) { + renderEventsSincePaint++; + } if (ev.event === "done" || ev.event === "error") { ctx.terminal = true; + // 正文高速到达时,同一个 ReadableStream chunk 里可能塞进大量 SSE frame。 + // handleSseEvent 的 DOM 更新由 rAF 节流;若这里直接一路吃到 done 并 return, + // 浏览器始终拿不到绘制机会,用户就只会在收尾时看到整段正文。终态前先让 + // 已排队的 rAF 落一次屏,保持最后一批文字也按流式过程可见。 + if (renderEventsSincePaint) await yieldToStreamPaint(); refreshProcs(); // run 收尾顺手校正后台进程条(run 中可能起了/杀了 proc) return; } + // 限制一次同步消费的渲染事件数。Flash 正文 token 很密时主动给主线程背压, + // 让 Markdown rAF 与自动滚底能逐帧执行;隐藏标签页无需等待不会触发的 rAF。 + if (renderEventsSincePaint >= 24) { + await yieldToStreamPaint(); + renderEventsSincePaint = 0; + } + } + // 即使单个网络 chunk 不到阈值,也在读取下一批前兑现本批 DOM 更新。部分浏览器 + // 会从内部缓冲连续 resolve reader.read(),只依赖网络 await 仍可能长期饿死绘制。 + if (renderEventsSincePaint) { + await yieldToStreamPaint(); + renderEventsSincePaint = 0; } } } +function yieldToStreamPaint() { + if (document.visibilityState === "hidden") return Promise.resolve(); + return new Promise(resolve => requestAnimationFrame(resolve)); +} + function parseSseFrame(frame) { const lines = frame.split("\n"); let event = "msg"; let dataLines = [];