From ee2d43dcbce9e7d822b88cf6d3125946230d83c8 Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:02:37 +0800 Subject: [PATCH] =?UTF-8?q?perf(gui):=20=E6=B5=81=E5=BC=8F=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=88=A4=E6=8D=AE=E6=94=B9=E7=9C=8B=20=5Fstreaming?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=20chatLoading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 旁观 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 全绿。 --- cmd/gui/renderer/app.js | 43 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 8a988424..863316a9 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -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(() => {