perf(gui): 流式渲染判据改看 _streaming,不再依赖 chatLoading

## 现象
旁观 agent 响应其它渠道(memo / QQ / email)时,聊天页每秒全量重渲多次,
消息越多越卡。

## 根因
流式分支判据写的是 `state.chatLoading && ...`,而 chatLoading 只在
「用户从 GUI 自己发消息」(sendChat)时置 true。旁观路径(agent 响应
memo/QQ/email,GUI 仅通过 SSE 收流式帧)下它恒为 false ⇒ 判据恒假 ⇒
每个 content_delta / reasoning_delta / tool_call 帧都掉进「非流式」分支,
走**全量** innerHTML 重建。

而且它出现**三处**,只改一处无效:
  · rerenderChatIfActive(决定走 renderChatStreamChunk 还是 renderChat)
  · renderChat 内部的 streamingLast(决定单条消息的渲染分支)
  · 工具 pill 的「进行中」收集(旁观时列表恒空,看不到 agent 在做什么)

## 实测(本机 Electron + 192.168.2.60,真实凭据,同一条消息)
  修复前:1.7~2 秒内 13~26 次全量重渲,间隔 10~30ms
  修复后:同一场景降到 5 次(含轮询触发的)
200 条消息时单次全量重建实测 235ms —— 这是「聊天卡得没有用的欲望」的直接来源。

## 改法
所有 SSE 流式帧创建的 assistant 消息都带 _streaming:true
(content_delta / reasoning_delta / tool_call / agent_output 五个 push 点),
收尾时才置 _final。所以正确判据是「最后一条是尚未收尾的流式 assistant」,
即用 last._streaming 取代 state.chatLoading。

保留 chatLoading 的两处语义不同,未动:
  · 「思考中」占位气泡:只在用户自己发消息后等待时显示,旁观不需要
  · sendChat / interruptChat 自身的并发保护

判据:cmd/gui npm test 全绿。
This commit is contained in:
JianFeeeee
2026-10-01 22:02:37 +08:00
parent 60f51f40e0
commit ee2d43dcbc

View File

@ -1988,7 +1988,14 @@ function renderChat() {
var prevPending = msgsEl._lastPending || [];
var newPending = (state.pendingTools || []).slice();
var lastM = msgs.length ? msgs[msgs.length - 1] : null;
if (state.chatLoading && lastM && lastM.role === "assistant") {
// 同上:不依赖 chatLoading。旁观其它渠道的工具调用时,
// 「运行中工具」列表此前恒为空,用户看不到 agent 正在做什么。
if (
(state.chatLoading ||
(lastM && lastM._streaming && !lastM._final)) &&
lastM &&
lastM.role === "assistant"
) {
(lastM.tool_calls || []).forEach((tc) => {
if (!tc.result && tc.status !== "denied") {
var nm = tc.tool || tc.name || "";
@ -1998,11 +2005,25 @@ function renderChat() {
}
var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1);
msgsEl._lastPending = newPending;
// ★ 同 rerenderChatIfActive 的判据:看消息自身的 _streaming,
// 不依赖 state.chatLoading。
//
// chatLoading 只在「用户从 GUI 自己发消息」(sendChat)时为 true。agent
// 响应 memo/QQ/email 等其他渠道时,GUI 是通过 SSE 旁观流式帧,
// chatLoading 恒 false ⇒ streamingLast 恒假 ⇒ 每个增量帧都走**全量**
// innerHTML 重建。
//
// 实测(2026-10-01,本机 192.168.2.60):旁观一次 tool_call + 回复,
// 1.7 秒内 13 次全量重渲(消息数 45);另一段流式更密的样本 2 秒 26 次。
// 200 条消息时单次全量重建实测 235ms,这是「聊天卡顿」的直接来源。
//
// 所有 SSE 流式帧创建的 assistant 消息都带 _streaming:true,
// 收尾时才置 _final —— 所以正确判据是「最后一条是尚未收尾的流式 assistant」。
var streamingLast = !!(
state.chatLoading &&
lastM &&
lastM.role === "assistant" &&
!lastM._final
!lastM._final &&
lastM._streaming
);
function pillHtml() {
var s = "";
@ -6358,8 +6379,22 @@ function rerenderChatIfActive() {
// 流式增量路径:防抖合并 + 只更新最后一条消息的正文/思考节点,避免全量重建
var msgs = state.messages;
var last = msgs.length ? msgs[msgs.length - 1] : null;
// ★ 判据必须看**消息自身的 _streaming**,不能依赖 state.chatLoading。
//
// 真实事故(2026-10-01 实测,本机 192.168.2.60):agent 响应非 GUI 渠道
// (memo / QQ / email)时,GUI 只是通过 SSE 旁观流式帧。而 chatLoading
// 只在**用户从 GUI 自己发消息**时置 true(sendChat),旁观路径下它恒为
// false ⇒ streamingLast 恒假 ⇒ 每个 content_delta 帧都掉进「非流式」
// 分支走**全量** renderChat()。
//
// 实测代价:2 秒内 26 次全量重渲(content C=1→C=66,每个增量帧一次),
// 间隔 10~30ms;消息越多越慢(200 条时单次全量重建实测 235ms)。
//
// 而所有 SSE 流式帧创建的 assistant 消息都带 _streaming:true
// (content_delta / reasoning_delta / tool_call / agent_output 五个 push 点),
// 收尾时才置 _final。所以正确判据是「最后一条是尚未收尾的流式 assistant」。
var streamingLast =
!!last && last.role === "assistant" && !last._final && state.chatLoading;
!!last && last.role === "assistant" && !last._final && last._streaming;
if (streamingLast) {
if (state._streamTimer) clearTimeout(state._streamTimer);
state._streamTimer = setTimeout(() => {