Skip to content

Commit 4c9d4fd

Browse files
authored
Merge pull request #23 from UiPath/fix/textual_batch_update
fix: use markdown append for llm chunks
2 parents 490c958 + 9ce84bf commit 4c9d4fd

File tree

4 files changed

+172
-79
lines changed

4 files changed

+172
-79
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.9"
3+
version = "0.0.10"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/dev/ui/panels/chat_panel.py

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Chat panel for displaying and interacting with chat messages."""
22

33
import time
4+
from collections import deque
45

56
from textual.app import ComposeResult
67
from textual.containers import Container, Vertical, VerticalScroll
@@ -12,6 +13,13 @@
1213

1314
from uipath.dev.models import ChatMessage, ExecutionRun
1415

16+
# Tunables for streaming performance
17+
STREAM_MIN_INTERVAL = 0.08 # seconds between updates while streaming
18+
STREAM_MIN_DELTA_CHARS = 8 # min new chars before we bother updating
19+
20+
# Limit how many message widgets we keep mounted to avoid DOM explosion.
21+
MAX_WIDGETS = 20
22+
1523

1624
class Prompt(Markdown):
1725
"""User prompt message bubble."""
@@ -36,12 +44,18 @@ class ChatPanel(Container):
3644

3745
_chat_widgets: dict[str, Markdown]
3846
_last_update_time: dict[str, float]
47+
_last_content: dict[str, str]
48+
_chat_view: VerticalScroll | None
49+
_chat_order: deque[str]
3950

4051
def __init__(self, **kwargs):
4152
"""Initialize the chat panel."""
4253
super().__init__(**kwargs)
4354
self._chat_widgets = {}
4455
self._last_update_time = {}
56+
self._last_content = {}
57+
self._chat_view = None
58+
self._chat_order = deque()
4559

4660
def compose(self) -> ComposeResult:
4761
"""Compose the UI layout."""
@@ -52,34 +66,46 @@ def compose(self) -> ComposeResult:
5266
id="chat-input",
5367
)
5468

55-
def update_messages(self, run: ExecutionRun) -> None:
69+
def on_mount(self) -> None:
70+
"""Called when the panel is mounted."""
71+
self._chat_view = self.query_one("#chat-view", VerticalScroll)
72+
73+
def refresh_messages(self, run: ExecutionRun) -> None:
5674
"""Update the chat panel with messages from the given execution run."""
57-
chat_view = self.query_one("#chat-view")
58-
chat_view.remove_children()
75+
assert self._chat_view is not None
76+
77+
self._chat_view.remove_children()
5978
self._chat_widgets.clear()
6079
self._last_update_time.clear()
80+
self._last_content.clear()
81+
self._chat_order.clear()
6182

6283
for chat_msg in run.messages:
6384
self.add_chat_message(
6485
ChatMessage(message=chat_msg, event=None, run_id=run.id),
6586
auto_scroll=False,
6687
)
6788

68-
chat_view.scroll_end(animate=False)
89+
# For a fresh run, always show the latest messages
90+
self._chat_view.scroll_end(animate=False)
6991

7092
def add_chat_message(
7193
self,
7294
chat_msg: ChatMessage,
7395
auto_scroll: bool = True,
7496
) -> None:
7597
"""Add or update a chat message bubble."""
76-
chat_view = self.query_one("#chat-view")
98+
assert self._chat_view is not None
99+
chat_view = self._chat_view
77100

78-
message = chat_msg.message
101+
should_autoscroll = auto_scroll and not chat_view.is_vertical_scrollbar_grabbed
79102

103+
message = chat_msg.message
80104
if message is None:
81105
return
82106

107+
message_id = message.message_id
108+
83109
widget_cls: type[Prompt] | type[Response] | type[Tool]
84110
if message.role == "user":
85111
widget_cls = Prompt
@@ -114,27 +140,82 @@ def add_chat_message(
114140

115141
content = "\n\n".join(content_lines)
116142

117-
existing = self._chat_widgets.get(message.message_id)
143+
prev_content = self._last_content.get(message_id)
144+
if prev_content is not None and content == prev_content:
145+
# We already rendered this exact content, no need to touch the UI.
146+
return
147+
148+
existing = self._chat_widgets.get(message_id)
118149
now = time.monotonic()
119-
last_update = self._last_update_time.get(message.message_id, 0.0)
150+
last_update = self._last_update_time.get(message_id, 0.0)
120151

121152
if existing:
122-
event = chat_msg.event
123-
should_update = (
124-
event
125-
and event.exchange
126-
and event.exchange.message
127-
and event.exchange.message.end is not None
128-
)
129-
if should_update or now - last_update > 0.15:
153+
prev_content_len = len(prev_content) if prev_content is not None else 0
154+
delta_len = len(content) - prev_content_len
155+
156+
def should_update() -> bool:
157+
event = chat_msg.event
158+
finished = (
159+
event
160+
and event.exchange
161+
and event.exchange.message
162+
and event.exchange.message.end is not None
163+
)
164+
165+
if finished:
166+
# Always paint the final state immediately.
167+
return True
168+
169+
# Throttle streaming: require both some time and a minimum delta size.
170+
if now - last_update < STREAM_MIN_INTERVAL:
171+
return False
172+
173+
# First streaming chunk for this message: allow update.
174+
if prev_content is None:
175+
return True
176+
177+
if delta_len < STREAM_MIN_DELTA_CHARS:
178+
return False
179+
180+
return True
181+
182+
if not should_update():
183+
return
184+
185+
# Fast path: message is growing by appending new text.
186+
if (
187+
isinstance(existing, Markdown)
188+
and prev_content is not None
189+
and content.startswith(prev_content)
190+
):
191+
delta = content[len(prev_content) :]
192+
if delta:
193+
# Streaming update: only append the new portion.
194+
existing.append(delta)
195+
else:
196+
# Fallback for non-monotonic changes: full update.
130197
existing.update(content)
131-
self._last_update_time[message.message_id] = now
132-
if auto_scroll:
133-
chat_view.scroll_end(animate=False)
198+
199+
self._last_content[message_id] = content
200+
self._last_update_time[message_id] = now
201+
134202
else:
203+
# First time we see this message: create a new widget.
135204
widget_instance = widget_cls(content)
136205
chat_view.mount(widget_instance)
137-
self._chat_widgets[message.message_id] = widget_instance
138-
self._last_update_time[message.message_id] = now
139-
if auto_scroll:
140-
chat_view.scroll_end(animate=False)
206+
self._chat_widgets[message_id] = widget_instance
207+
self._last_update_time[message_id] = now
208+
self._last_content[message_id] = content
209+
self._chat_order.append(message_id)
210+
211+
# Prune oldest widgets to keep DOM size bounded
212+
if len(self._chat_order) > MAX_WIDGETS:
213+
oldest_id = self._chat_order.popleft()
214+
old_widget = self._chat_widgets.pop(oldest_id, None)
215+
self._last_update_time.pop(oldest_id, None)
216+
self._last_content.pop(oldest_id, None)
217+
if old_widget is not None:
218+
old_widget.remove()
219+
220+
if should_autoscroll:
221+
chat_view.scroll_end(animate=False)

0 commit comments

Comments
 (0)