fix(web): keep streamed replies painting incrementally

This commit is contained in:
caoqianming 2026-08-20 14:29:39 +08:00
parent 754f6e07a7
commit 9451d21de8
2 changed files with 34 additions and 0 deletions

View File

@ -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")

View File

@ -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 = [];