fix(webui): 聊天流式增量渲染(移植 GUI renderChatStreamChunk 方案)

问题:webui 流式推理阶段虽有 90ms 防抖,但每次防抖到期仍是
全量 innerHTML 重建,视觉上「一下渲染一大块」。

修复:移植 GUI 的增量渲染方案——
- rerenderChat() 在流式中(chatLoading && 最后一条未 _final)走
  增量路径:90ms 合并 chunk 后只调 renderChatStreamChunk()
- renderChatStreamChunk 仅更新最后一条消息节点:
  · 正文 >200字符 或 >300ms 才 renderMd(节流 markdown parse)
  · 小增量纯文本 createTextNode 追加,零 parse 开销
  · 思考预览只刷 .reasoning-preview 文本
  · 结构变化时兜底全量 renderChat()
- 工具卡/历史等非流式变化仍走全量路径
This commit is contained in:
JianFeeeee
2026-08-24 22:40:48 +08:00
parent 8157772132
commit 121a2b9ace

View File

@ -3164,12 +3164,24 @@ background:
badge.style.display = "none";
}
// 流式渲染防抖SSE 高频推送时合并为一次重建,避免每个 chunk 全量 innerHTML。
// 聊天内容变化 → 短超时后只重渲聊天(跳过星图/终端/命令历史等无关重建)。
// 流式渲染分发流式增量只更新最后一条正文vs 全量重建
var _rerenderTimer = null;
function rerenderChat(full) {
// 流式中且非强制全量:走增量路径(防抖合并 chunk只更新最后一条消息节点
if (!full && state.chatLoading) {
var msgs = state.messages;
var last = msgs.length ? msgs[msgs.length - 1] : null;
if (last && last.role === "assistant" && !last._final) {
if (_rerenderTimer) return; // 已有排程的增量更新
_rerenderTimer = setTimeout(function () {
_rerenderTimer = null;
renderChatStreamChunk();
}, 90);
return;
}
}
// 非流式(完成/工具/历史变化):全量重渲(含防抖合并)
if (_rerenderTimer) clearTimeout(_rerenderTimer);
// 流式增量(非全量)走短防抖,仅重渲聊天;工具调用/历史变化等需全量重渲
_rerenderTimer = setTimeout(function () {
_rerenderTimer = null;
renderChat();
@ -3180,6 +3192,47 @@ background:
}
}, 90);
}
// 流式增量渲染(移植自 GUI仅更新最后一条 assistant 消息的正文与思考预览,
// 不重建 DOM。正文节流 parse>200 字符或 >300ms 才 renderMd小增量纯文本追加。
function renderChatStreamChunk() {
var msgsEl = document.getElementById("chat-msgs");
var last = state.messages.length ? state.messages[state.messages.length - 1] : null;
if (!msgsEl || !last) return;
var el = msgsEl.lastElementChild;
if (!el) { renderChat(); return; }
var textEl = el.querySelector(".msg-bubble .text");
var c = last.content || "";
if (textEl && c) {
var now = Date.now();
var lastParse = el.__lastParse || 0;
var lastLen = el.__lastLen || 0;
if (c.length - lastLen > 200 || now - lastParse > 300) {
textEl.innerHTML = renderMd(c);
el.__lastParse = now;
el.__lastLen = c.length;
} else {
var tail = c.slice(lastLen);
if (tail) textEl.appendChild(document.createTextNode(tail));
el.__lastLen = c.length;
}
if (state.chatStick !== false) {
try { msgsEl.scrollTop = msgsEl.scrollHeight; } catch (e) {}
}
return;
}
// 思考预览更新(流式中折叠,只刷 preview 文本)
var rcPrev = el.querySelector(".reasoning-preview");
if (rcPrev && last.reasoning_content) {
rcPrev.textContent = last.reasoning_content.replace(/[\s\n]+/g, " ").slice(0, 60);
if (state.chatStick !== false) {
try { msgsEl.scrollTop = msgsEl.scrollHeight; } catch (e) {}
}
return;
}
// 结构变化兜底:全量
renderChat();
}
function toggleToolCall(el) {
var d = el.querySelector(".tc-detail");
if (!d) return;