From ecb8ac9a8014e5479da99ca702f66316811ffaa6 Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Sun, 12 Apr 2026 15:22:56 +0800 Subject: [PATCH] fix(stapp): eliminate ghost duplicate of last text block on new message Root cause: stream loop emitted a bare st.empty() heartbeat per tick, accumulating dozens of empty slots under chat_message. On the next rerun the history replay path had no such slots, so Streamlit's incremental DOM diff misaligned and left the previous render of the last text block as a grayed-out ghost. Fix: unify streaming and history replay under the same shell (slot = st.empty(); with slot.container(): render_segments(...)). Heartbeat now re-enters the same slot each tick; when response is unchanged Streamlit's diff is a no-op (no flicker), but the container() call still lets StopException propagate so abort keeps working. Simplified render_segments from 5 params (placeholders/rendered_cache/force_text) to 2 (segments, suffix). Net: +19 -20 lines. Verified: no ghost, abort interrupts mid-stream, per-turn fold-collapse preserved. --- frontends/stapp.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/frontends/stapp.py b/frontends/stapp.py index 67e8355..f4ee915 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -89,21 +89,15 @@ def fold_turns(text): segments.append({'type': 'fold', 'title': title, 'content': content}) else: segments.append({'type': 'text', 'content': marker + content}) return segments -def render_segments(segments, placeholders=None, rendered_cache=None, suffix='', force_text=False): - def _render_seg(target, seg, suf=''): +def render_segments(segments, suffix=''): + # 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。 + # heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁; + # 但 container/markdown 本身是 API 调用,StopException 仍会被抛出(abort 照常起作用)。 + for seg in segments: if seg['type'] == 'fold': - with target.expander(seg['title'], expanded=False): st.markdown(seg['content']) - else: target.markdown(seg['content'] + suf, unsafe_allow_html=not not suf) - if placeholders is not None: - while len(placeholders) < len(segments): - placeholders.append(st.empty()); rendered_cache.append(None) - for i, seg in enumerate(segments): - if rendered_cache[i] != (seg, suffix): - if not force_text or seg['type'] == 'text': - with placeholders[i].container(): _render_seg(st, seg, suffix) - rendered_cache[i] = (seg, suffix) - else: - for seg in segments: _render_seg(st, seg) + with st.expander(seg['title'], expanded=False): st.markdown(seg['content']) + else: + st.markdown(seg['content'] + suffix, unsafe_allow_html=not not suffix) def agent_backend_stream(prompt): display_queue = agent.put_task(prompt, source="user") @@ -123,8 +117,11 @@ def agent_backend_stream(prompt): if "messages" not in st.session_state: st.session_state.messages = [] for msg in st.session_state.messages: with st.chat_message(msg["role"]): - if msg["role"] == "assistant": render_segments(fold_turns(msg["content"])) - else: st.markdown(msg["content"]) + # 用 slot=st.empty() + with slot.container(): ... 的外壳,DOM 路径和流式渲染完全一致,跨 rerun 对齐 + slot = st.empty() + with slot.container(): + if msg["role"] == "assistant": render_segments(fold_turns(msg["content"])) + else: st.markdown(msg["content"]) # IME composition fix (macOS only) - prevents Enter from submitting during CJK input if os.name != 'nt': @@ -136,11 +133,13 @@ if prompt := st.chat_input("请输入指令"): with st.chat_message("user"): st.markdown(prompt) with st.chat_message("assistant"): - turns = []; cache = []; response = '' + slot = st.empty(); response = '' + CURSOR = '' for response in agent_backend_stream(prompt): - render_segments(fold_turns(response), placeholders=turns, rendered_cache=cache, suffix='') - st.empty() # force Streamlit to check StopException on every iteration (incl. heartbeat) - render_segments(fold_turns(response), placeholders=turns, rendered_cache=cache, force_text=True) + # 每轮整块重画(含 heartbeat 空转):segments 不变时 Streamlit diff 零变更 → 不闪烁; + # 而 slot.container() 调用本身保证 Streamlit 能抛 StopException(abort 生效) + with slot.container(): render_segments(fold_turns(response), suffix=CURSOR) + with slot.container(): render_segments(fold_turns(response)) # 收尾去光标 st.session_state.messages.append({"role": "assistant", "content": response}) st.session_state.last_reply_time = int(time.time())