Skip to content

Commit 4932ac1

Browse files
authored
Merge pull request #550 from UiPath/feat/chat_interface
feat: chat interface
2 parents 5f1cae5 + 490019f commit 4932ac1

11 files changed

Lines changed: 450 additions & 69 deletions

File tree

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"
3-
version = "2.1.36"
3+
version = "2.1.37"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.10"

src/uipath/_cli/_dev/_terminal/__init__.py

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99

1010
import pyperclip # type: ignore[import-untyped]
1111
from rich.traceback import Traceback
12+
from textual import on
1213
from textual.app import App, ComposeResult
1314
from textual.binding import Binding
1415
from textual.containers import Container, Horizontal
15-
from textual.widgets import Button, Footer, ListView, RichLog
16+
from textual.widgets import Button, Footer, Input, ListView, RichLog
17+
18+
from uipath.agent.conversation import UiPathConversationEvent
1619

1720
from ..._runtime._contracts import (
1821
UiPathErrorContract,
@@ -24,16 +27,18 @@
2427
from ._components._details import RunDetailsPanel
2528
from ._components._history import RunHistoryPanel
2629
from ._components._new import NewRunPanel
27-
from ._components._resume import ResumePanel
2830
from ._models._execution import ExecutionRun
2931
from ._models._messages import LogMessage, TraceMessage
30-
from ._traces._exporter import RunContextExporter
31-
from ._traces._logger import RunContextLogHandler
32+
from ._utils._chat import RunContextChatHandler, build_user_message_event
33+
from ._utils._exporter import RunContextExporter
34+
from ._utils._logger import RunContextLogHandler
3235

3336

3437
class UiPathDevTerminal(App[Any]):
35-
"""UiPath development terminal interface."""
38+
"""UiPath debugging terminal interface."""
3639

40+
TITLE = "UiPath Debugging Terminal"
41+
SUB_TITLE = "Interactive debugging interface for UiPath Python projects"
3742
CSS_PATH = Path(__file__).parent / "_styles" / "terminal.tcss"
3843

3944
BINDINGS = [
@@ -91,8 +96,6 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
9196
await self.action_execute_run()
9297
elif event.button.id == "cancel-btn":
9398
await self.action_cancel()
94-
elif event.button.id == "resume-btn":
95-
await self.action_resume()
9699

97100
async def on_list_view_selected(self, event: ListView.Selected) -> None:
98101
"""Handle run selection from history."""
@@ -104,6 +107,31 @@ async def on_list_view_selected(self, event: ListView.Selected) -> None:
104107
if run:
105108
self._show_run_details(run)
106109

110+
@on(Input.Submitted, "#chat-input")
111+
async def handle_chat_input(self, event: Input.Submitted) -> None:
112+
"""Handle user submitting text into the chat."""
113+
user_text = event.value.strip()
114+
if not user_text:
115+
return
116+
117+
details_panel = self.query_one("#details-panel", RunDetailsPanel)
118+
if details_panel and details_panel.current_run:
119+
if details_panel.current_run.status != "suspended":
120+
self.app.notify(
121+
"Wait for agent response...", timeout=1.5, severity="warning"
122+
)
123+
return
124+
self._handle_chat_event(
125+
build_user_message_event(
126+
user_text=user_text,
127+
conversation_id=details_panel.current_run.id,
128+
),
129+
details_panel.current_run.id,
130+
)
131+
details_panel.current_run.resume_data = {"value": user_text}
132+
asyncio.create_task(self._execute_runtime(details_panel.current_run))
133+
event.input.clear()
134+
107135
async def action_new_run(self) -> None:
108136
"""Show new run panel."""
109137
new_panel = self.query_one("#new-run-panel")
@@ -116,20 +144,6 @@ async def action_cancel(self) -> None:
116144
"""Cancel and return to new run view."""
117145
await self.action_new_run()
118146

119-
async def action_resume(self) -> None:
120-
"""Resume the suspended run."""
121-
details_panel = self.query_one("#details-panel", RunDetailsPanel)
122-
if details_panel and details_panel.current_run:
123-
input: Dict[str, Any] = {}
124-
input_data = self.query_one("#resume-panel", ResumePanel).get_input_values()
125-
try:
126-
input = json.loads(input_data)
127-
except json.JSONDecodeError:
128-
return
129-
details_panel.current_run.resume_data = input
130-
asyncio.create_task(self._execute_runtime(details_panel.current_run))
131-
details_panel.switch_tab("run-tab")
132-
133147
async def action_execute_run(self) -> None:
134148
"""Execute a new run with UiPath runtime."""
135149
new_run_panel = self.query_one("#new-run-panel", NewRunPanel)
@@ -179,7 +193,10 @@ async def _execute_runtime(self, run: ExecutionRun):
179193
execution_id=run.id,
180194
logs_min_level=env.get("LOG_LEVEL", "INFO"),
181195
log_handler=RunContextLogHandler(
182-
run_id=run.id, on_log=self._handle_log_message
196+
run_id=run.id, callback=self._handle_log_message
197+
),
198+
chat_handler=RunContextChatHandler(
199+
run_id=run.id, callback=self._handle_chat_event
183200
),
184201
)
185202

@@ -271,6 +288,15 @@ def _handle_log_message(self, log_msg: LogMessage):
271288
details_panel = self.query_one("#details-panel", RunDetailsPanel)
272289
details_panel.add_log(log_msg)
273290

291+
def _handle_chat_event(
292+
self, event: UiPathConversationEvent, execution_id: str
293+
) -> None:
294+
updated_chat_message = self.runs[execution_id].add_message(event)
295+
if not updated_chat_message:
296+
return
297+
details_panel = self.app.query_one("#details-panel", RunDetailsPanel)
298+
details_panel.add_chat_message(updated_chat_message, execution_id)
299+
274300
def _add_info_log(self, run: ExecutionRun, message: str):
275301
"""Add info log to run."""
276302
timestamp = datetime.now()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import time
2+
from typing import Dict, List, Union
3+
4+
from textual.app import ComposeResult
5+
from textual.containers import Container, Vertical, VerticalScroll
6+
from textual.widgets import Input, Markdown
7+
8+
from uipath.agent.conversation import (
9+
UiPathConversationMessage,
10+
UiPathExternalValue,
11+
UiPathInlineValue,
12+
)
13+
14+
15+
class Prompt(Markdown):
16+
pass
17+
18+
19+
class Response(Markdown):
20+
BORDER_TITLE = "ai"
21+
22+
23+
class Tool(Markdown):
24+
BORDER_TITLE = "tool"
25+
26+
27+
class ChatPanel(Container):
28+
"""Panel for displaying and interacting with chat messages."""
29+
30+
_chat_widgets: Dict[str, Markdown]
31+
_last_update_time: Dict[str, float]
32+
33+
def __init__(self, **kwargs):
34+
super().__init__(**kwargs)
35+
self._chat_widgets = {}
36+
self._last_update_time = {}
37+
38+
def compose(self) -> ComposeResult:
39+
with Vertical(id="chat-container"):
40+
yield VerticalScroll(id="chat-view")
41+
yield Input(
42+
placeholder="Type your message and press Enter...",
43+
id="chat-input",
44+
)
45+
46+
def add_chat_message(self, chat_msg: UiPathConversationMessage) -> None:
47+
"""Add or update a chat message bubble."""
48+
chat_view = self.query_one("#chat-view")
49+
50+
widget_cls: Union[type[Prompt], type[Response], type[Tool]]
51+
if chat_msg.role == "user":
52+
widget_cls = Prompt
53+
prefix = "👤"
54+
elif chat_msg.role == "assistant":
55+
widget_cls = Response
56+
prefix = "🤖"
57+
else:
58+
widget_cls = Response
59+
prefix = "⚒️"
60+
61+
parts: List[str] = []
62+
if chat_msg.content_parts:
63+
for part in chat_msg.content_parts:
64+
if part.mime_type.startswith("text/"):
65+
if isinstance(part.data, UiPathInlineValue):
66+
parts.append(part.data.inline or "")
67+
elif isinstance(part.data, UiPathExternalValue):
68+
# maybe fetch from URL or just show a placeholder
69+
parts.append(f"[external: {part.data.url}]")
70+
71+
text_block = "\n".join(parts).strip()
72+
content_lines = [f"{prefix} {text_block}"] if text_block else []
73+
74+
if chat_msg.tool_calls:
75+
for call in chat_msg.tool_calls:
76+
args = call.arguments
77+
content_lines.append(f"🛠 **{call.name}**\n{args}")
78+
79+
if not content_lines:
80+
return
81+
82+
content = "\n\n".join(content_lines)
83+
84+
existing = self._chat_widgets.get(chat_msg.message_id)
85+
now = time.monotonic()
86+
last_update = self._last_update_time.get(chat_msg.message_id, 0.0)
87+
88+
if existing:
89+
if now - last_update > 0.15:
90+
existing.update(content)
91+
self._last_update_time[chat_msg.message_id] = now
92+
chat_view.scroll_end(animate=False)
93+
else:
94+
widget_instance = widget_cls(content)
95+
chat_view.mount(widget_instance)
96+
self._chat_widgets[chat_msg.message_id] = widget_instance
97+
self._last_update_time[chat_msg.message_id] = now
98+
chat_view.scroll_end(animate=False)

src/uipath/_cli/_dev/_terminal/_components/_details.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
from textual.app import ComposeResult
44
from textual.containers import Container, Horizontal, Vertical
55
from textual.reactive import reactive
6-
from textual.widgets import RichLog, TabbedContent, TabPane, Tree
6+
from textual.widgets import Input, RichLog, TabbedContent, TabPane, Tree
77
from textual.widgets.tree import TreeNode
88

9+
from uipath.agent.conversation import UiPathConversationMessage
10+
911
from .._models._execution import ExecutionRun
1012
from .._models._messages import LogMessage, TraceMessage
11-
from ._resume import ResumePanel
13+
from ._chat import ChatPanel
1214

1315

1416
class SpanDetailsDisplay(Container):
@@ -116,8 +118,8 @@ def compose(self) -> ComposeResult:
116118
classes="detail-log",
117119
)
118120

119-
with TabPane("Resume", id="resume-tab"):
120-
yield ResumePanel(id="resume-panel")
121+
with TabPane("Chat", id="chat-tab"):
122+
yield ChatPanel(id="chat-panel")
121123

122124
def watch_current_run(
123125
self, old_value: Optional[ExecutionRun], new_value: Optional[ExecutionRun]
@@ -151,9 +153,9 @@ def switch_tab(self, tab_id: str) -> None:
151153
tabbed = self.query_one(TabbedContent)
152154
tabbed.active = tab_id
153155

154-
def _update_resume_tab(self, run: ExecutionRun) -> None:
155-
resume_panel = self.query_one("#resume-panel", ResumePanel)
156-
resume_panel.display = run.status == "suspended"
156+
def _update_chat_tab(self, run: ExecutionRun) -> None:
157+
chat_input = self.query_one("#chat-input", Input)
158+
chat_input.disabled = run.status == "completed" or run.status == "failed"
157159

158160
def _flatten_values(self, value: object, prefix: str = "") -> list[str]:
159161
"""Flatten nested dict/list structures into dot-notation paths."""
@@ -210,7 +212,7 @@ def _write_block(
210212

211213
def _show_run_details(self, run: ExecutionRun):
212214
"""Display detailed information about the run in the Details tab."""
213-
self._update_resume_tab(run)
215+
self._update_chat_tab(run)
214216

215217
run_details_log = self.query_one("#run-details-log", RichLog)
216218
run_details_log.clear()
@@ -427,6 +429,15 @@ def add_log(self, log_msg: LogMessage):
427429
else:
428430
logs_log.write(log_msg.message)
429431

432+
def add_chat_message(
433+
self, chat_msg: UiPathConversationMessage, run_id: str
434+
) -> None:
435+
"""Add a chat message to the display."""
436+
if not self.current_run or run_id != self.current_run.id:
437+
return
438+
chat_panel = self.query_one("#chat-panel", ChatPanel)
439+
chat_panel.add_chat_message(chat_msg)
440+
430441
def clear_display(self):
431442
"""Clear both traces and logs display."""
432443
run_details_log = self.query_one("#run-details-log", RichLog)

src/uipath/_cli/_dev/_terminal/_components/_resume.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

src/uipath/_cli/_dev/_terminal/_models/_execution.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
from rich.text import Text
77

8+
from uipath.agent.conversation import UiPathConversationEvent, UiPathConversationMessage
9+
810
from ...._runtime._contracts import UiPathErrorContract
11+
from .._utils._chat import ConversationAggregator
912
from ._messages import LogMessage, TraceMessage
1013

1114

@@ -24,6 +27,7 @@ def __init__(self, entrypoint: str, input_data: Dict[str, Any]):
2427
self.traces: List[TraceMessage] = []
2528
self.logs: List[LogMessage] = []
2629
self.error: Optional[UiPathErrorContract] = None
30+
self.chat_aggregator = ConversationAggregator()
2731

2832
@property
2933
def duration(self) -> str:
@@ -64,3 +68,12 @@ def display_name(self) -> Text:
6468
text.append(f"[{duration_str:<6}]")
6569

6670
return text
71+
72+
@property
73+
def messages(self) -> List[UiPathConversationMessage]:
74+
return list(self.chat_aggregator.messages.values())
75+
76+
def add_message(
77+
self, event: UiPathConversationEvent
78+
) -> Optional[UiPathConversationMessage]:
79+
return self.chat_aggregator.add_event(event)

src/uipath/_cli/_dev/_terminal/_styles/terminal.tcss

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,29 @@ SpanDetailsDisplay {
223223
TextArea.invalid {
224224
border: tall red;
225225
}
226+
227+
228+
Prompt {
229+
background: $primary 5%;
230+
color: $text;
231+
margin-right: 8;
232+
margin-left: 1;
233+
padding: 1 1 0 1;
234+
}
235+
Response, Tool {
236+
border: wide $success;
237+
background: $success 5%;
238+
color: $text;
239+
margin: 1;
240+
margin-left: 8;
241+
padding: 1 1 0 1;
242+
}
243+
244+
#chat-container{
245+
background: $surface;
246+
}
247+
248+
#chat-input{
249+
dock: bottom;
250+
margin: 1;
251+
}

0 commit comments

Comments
 (0)