diff --git a/pyproject.toml b/pyproject.toml index 2f617f7..f04eb75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,8 @@ description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-runtime>=0.1.0, <0.2.0", + "uipath-core>=0.0.6, <0.1.0", + "uipath-runtime>=0.1.2, <0.2.0", "textual>=6.6.0, <7.0.0", "pyperclip>=1.11.0, <2.0.0", ] diff --git a/src/uipath/dev/__init__.py b/src/uipath/dev/__init__.py index 9d056af..8def569 100644 --- a/src/uipath/dev/__init__.py +++ b/src/uipath/dev/__init__.py @@ -4,7 +4,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, cast import pyperclip # type: ignore[import-untyped] from textual import on @@ -18,7 +18,14 @@ from uipath.dev.infrastructure import ( patch_textual_stderr, ) -from uipath.dev.models import ExecutionRun, LogMessage, TraceMessage +from uipath.dev.models import ( + ChatMessage, + ExecutionMode, + ExecutionRun, + LogMessage, + TraceMessage, +) +from uipath.dev.models.chat import get_user_message, get_user_message_event from uipath.dev.services import RunService from uipath.dev.ui.panels import NewRunPanel, RunDetailsPanel, RunHistoryPanel @@ -64,6 +71,7 @@ def __init__( on_run_updated=self._on_run_updated, on_log=self._on_log_for_ui, on_trace=self._on_trace_for_ui, + on_chat=self._on_chat_for_ui, ) # Just defaults for convenience @@ -96,9 +104,11 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "new-run-btn": await self.action_new_run() elif event.button.id == "execute-btn": - await self.action_execute_run() + await self.action_execute_run(mode=ExecutionMode.RUN) elif event.button.id == "debug-btn": - await self.action_debug_run() + await self.action_execute_run(mode=ExecutionMode.DEBUG) + elif event.button.id == "chat-btn": + await self.action_execute_run(mode=ExecutionMode.CHAT) elif event.button.id == "cancel-btn": await self.action_cancel() elif event.button.id == "debug-step-btn": @@ -135,9 +145,23 @@ async def handle_chat_input(self, event: Input.Submitted) -> None: return if details_panel.current_run.status == "suspended": - details_panel.current_run.resume_data = {"message": user_text} + details_panel.current_run.resume_data = {"value": user_text} + else: + msg = get_user_message(user_text) + msg_ev = get_user_message_event( + user_text, conversation_id=details_panel.current_run.id + ) + self._on_chat_for_ui( + ChatMessage( + event=msg_ev, + message=msg, + run_id=details_panel.current_run.id, + ) + ) + details_panel.current_run.input_data = {"messages": [msg]} asyncio.create_task(self._execute_runtime(details_panel.current_run)) + event.input.clear() async def action_new_run(self) -> None: @@ -152,10 +176,10 @@ async def action_cancel(self) -> None: """Cancel and return to new run view.""" await self.action_new_run() - async def action_execute_run(self) -> None: + async def action_execute_run(self, mode: ExecutionMode) -> None: """Execute a new run based on NewRunPanel inputs.""" new_run_panel = self.query_one("#new-run-panel", NewRunPanel) - entrypoint, input_data, conversational = new_run_panel.get_input_values() + entrypoint, input_data = new_run_panel.get_input_values() if not entrypoint: return @@ -165,7 +189,7 @@ async def action_execute_run(self) -> None: except json.JSONDecodeError: return - run = ExecutionRun(entrypoint, input_payload, conversational) + run = ExecutionRun(entrypoint, input_payload, mode=mode) history_panel = self.query_one("#history-panel", RunHistoryPanel) history_panel.add_run(run) @@ -174,36 +198,10 @@ async def action_execute_run(self) -> None: self._show_run_details(run) - if not run.conversational: - asyncio.create_task(self._execute_runtime(run)) - else: + if mode == ExecutionMode.CHAT: self._focus_chat_input() - - async def action_debug_run(self) -> None: - """Execute a new run in debug mode (step-by-step).""" - new_run_panel = self.query_one("#new-run-panel", NewRunPanel) - entrypoint, input_data, conversational = new_run_panel.get_input_values() - - if not entrypoint: - return - - try: - input_payload: dict[str, Any] = json.loads(input_data) - except json.JSONDecodeError: - return - - # Create run with debug=True - run = ExecutionRun(entrypoint, input_payload, conversational, debug=True) - - history_panel = self.query_one("#history-panel", RunHistoryPanel) - history_panel.add_run(run) - - self.run_service.register_run(run) - - self._show_run_details(run) - - # start execution in debug mode (it will pause immediately) - asyncio.create_task(self._execute_runtime(run)) + else: + asyncio.create_task(self._execute_runtime(run)) async def action_debug_step(self) -> None: """Step to next breakpoint in debug mode.""" @@ -267,6 +265,14 @@ def _on_trace_for_ui(self, trace_msg: TraceMessage) -> None: details_panel = self.query_one("#details-panel", RunDetailsPanel) details_panel.add_trace(trace_msg) + def _on_chat_for_ui( + self, + chat_msg: ChatMessage, + ) -> None: + """Append/refresh chat messages in the UI.""" + details_panel = self.query_one("#details-panel", RunDetailsPanel) + details_panel.add_chat_message(chat_msg) + def _show_run_details(self, run: ExecutionRun) -> None: """Show details panel for a specific run.""" new_panel = self.query_one("#new-run-panel") @@ -289,7 +295,9 @@ def _add_subprocess_log(self, level: str, message: str) -> None: def add_log() -> None: details_panel = self.query_one("#details-panel", RunDetailsPanel) - run = getattr(details_panel, "current_run", None) + run: ExecutionRun = cast( + ExecutionRun, getattr(details_panel, "current_run", None) + ) if run: log_msg = LogMessage(run.id, level, message, datetime.now()) # Route through RunService so state + UI stay in sync diff --git a/src/uipath/dev/models/__init__.py b/src/uipath/dev/models/__init__.py index c5081aa..fce14a7 100644 --- a/src/uipath/dev/models/__init__.py +++ b/src/uipath/dev/models/__init__.py @@ -1,10 +1,12 @@ """UiPath Dev Console models module.""" -from uipath.dev.models.execution import ExecutionRun -from uipath.dev.models.messages import LogMessage, TraceMessage +from uipath.dev.models.execution import ExecutionMode, ExecutionRun +from uipath.dev.models.messages import ChatMessage, LogMessage, TraceMessage __all__ = [ "ExecutionRun", + "ExecutionMode", + "ChatMessage", "LogMessage", "TraceMessage", ] diff --git a/src/uipath/dev/models/chat.py b/src/uipath/dev/models/chat.py new file mode 100644 index 0000000..35a4ec0 --- /dev/null +++ b/src/uipath/dev/models/chat.py @@ -0,0 +1,236 @@ +"""Aggregates conversation messages from conversation events.""" + +from datetime import datetime +from uuid import uuid4 + +from uipath.core.chat import ( + UiPathConversationContentPart, + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, + UiPathConversationEvent, + UiPathConversationExchangeEvent, + UiPathConversationMessage, + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, + UiPathConversationMessageStartEvent, + UiPathConversationToolCall, + UiPathConversationToolCallResult, + UiPathInlineValue, +) + + +class ChatEvents: + """Incrementally builds messages from UiPathConversationEvents.""" + + messages: dict[str, UiPathConversationMessage] + + def __init__(self) -> None: + """Initialize the chat events aggregator.""" + self.messages = {} + + def add(self, event: UiPathConversationEvent) -> UiPathConversationMessage | None: + """Process an incoming conversation-level event and return the current message snapshot if applicable.""" + if not event.exchange or not event.exchange.message: + return None + + ev = event.exchange.message + msg = self.messages.get(ev.message_id) + + if not msg: + msg = UiPathConversationMessage( + message_id=ev.message_id, + role=self.get_role(ev), + content_parts=[], + tool_calls=[], + created_at=self.get_timestamp(ev), + updated_at=self.get_timestamp(ev), + ) + self.messages[ev.message_id] = msg + + # --- Handle content parts (text, JSON, etc.) --- + if ev.content_part: + cp_event = ev.content_part + + existing = next( + ( + cp + for cp in (msg.content_parts or []) + if cp.content_part_id == cp_event.content_part_id + ), + None, + ) + + # Start of a new content part + if cp_event.start and not existing: + new_cp = UiPathConversationContentPart( + content_part_id=cp_event.content_part_id, + mime_type=cp_event.start.mime_type, + data=UiPathInlineValue(inline=""), + citations=[], + is_transcript=None, + is_incomplete=True, + ) + if msg.content_parts is None: + msg.content_parts = [] + msg.content_parts.append(new_cp) + existing = new_cp + + # Chunk for an existing part (or backfill if start missing) + if cp_event.chunk: + if not existing: + new_cp = UiPathConversationContentPart( + content_part_id=cp_event.content_part_id, + mime_type="text/plain", # fallback if start missing + data=UiPathInlineValue(inline=""), + citations=[], + is_transcript=None, + is_incomplete=True, + ) + if msg.content_parts is None: + msg.content_parts = [] + msg.content_parts.append(new_cp) + existing = new_cp + + if isinstance(existing.data, UiPathInlineValue): + existing.data.inline += cp_event.chunk.data or "" + + if cp_event.end and existing: + existing.is_incomplete = bool(cp_event.end.interrupted) + + # --- Handle tool calls --- + if ev.tool_call: + tc_event = ev.tool_call + existing_tool_call = next( + ( + tc + for tc in (msg.tool_calls or []) + if tc.tool_call_id == tc_event.tool_call_id + ), + None, + ) + + # Start of a tool call + if tc_event.start: + if not existing_tool_call: + new_tc = UiPathConversationToolCall( + tool_call_id=tc_event.tool_call_id, + name=tc_event.start.tool_name, + arguments=None, # args will arrive as JSON content part + timestamp=tc_event.start.timestamp, + result=None, + ) + if msg.tool_calls is None: + msg.tool_calls = [] + msg.tool_calls.append(new_tc) + existing_tool_call = new_tc + else: + existing_tool_call.name = ( + tc_event.start.tool_name or existing_tool_call.name + ) + existing_tool_call.timestamp = ( + tc_event.start.timestamp or existing_tool_call.timestamp + ) + + # End of a tool call + if tc_event.end: + if not existing_tool_call: + existing_tool_call = UiPathConversationToolCall( + tool_call_id=tc_event.tool_call_id, + name="", # unknown until start seen + arguments=None, + ) + if msg.tool_calls is None: + msg.tool_calls = [] + msg.tool_calls.append(existing_tool_call) + + existing_tool_call.result = UiPathConversationToolCallResult( + timestamp=tc_event.end.timestamp, + value=tc_event.end.result, + is_error=tc_event.end.is_error, + cancelled=tc_event.end.cancelled, + ) + + msg.updated_at = self.get_timestamp(ev) + + return msg + + def get_timestamp(self, ev: UiPathConversationMessageEvent) -> str: + """Choose timestamp from event if available, else fallback.""" + if ev.start and ev.start.timestamp: + return ev.start.timestamp + return datetime.now().isoformat() + + def get_role(self, ev: UiPathConversationMessageEvent) -> str: + """Infer the role of the message from the event.""" + if ev.start and ev.start.role: + return ev.start.role + return "assistant" + + +def get_user_message(user_text: str) -> UiPathConversationMessage: + """Build a user message from text input.""" + return UiPathConversationMessage( + message_id=str(uuid4()), + created_at=datetime.now().isoformat(), + updated_at=datetime.now().isoformat(), + content_parts=[ + UiPathConversationContentPart( + content_part_id=str(uuid4()), + mime_type="text/plain", + data=UiPathInlineValue(inline=user_text), + ) + ], + role="user", + ) + + +def get_user_message_event( + user_text: str, conversation_id: str, role: str = "user" +) -> UiPathConversationEvent: + """Build a conversation event representing a user message from text input.""" + message_id = str(uuid4()) + content_part_id = str(uuid4()) + timestamp = datetime.now().isoformat() + + msg_start = UiPathConversationMessageStartEvent( + role=role, + timestamp=timestamp, + ) + + cp_start = UiPathConversationContentPartStartEvent(mime_type="text/plain") + cp_chunk = UiPathConversationContentPartChunkEvent(data=user_text) + cp_end = UiPathConversationContentPartEndEvent() + + content_event = UiPathConversationContentPartEvent( + content_part_id=content_part_id, + start=cp_start, + chunk=cp_chunk, + end=cp_end, + ) + + msg_event = UiPathConversationMessageEvent( + message_id=message_id, + start=msg_start, + content_part=content_event, + end=UiPathConversationMessageEndEvent(), + ) + + exchange_event = UiPathConversationExchangeEvent( + exchange_id=str(uuid4()), + message=msg_event, + ) + + conversation_event = UiPathConversationEvent( + exchange=exchange_event, conversation_id=conversation_id + ) + + return conversation_event + + +__all__ = [ + "ChatEvents", + "get_user_message", + "get_user_message_event", +] diff --git a/src/uipath/dev/models/execution.py b/src/uipath/dev/models/execution.py index 91f3524..5293bd6 100644 --- a/src/uipath/dev/models/execution.py +++ b/src/uipath/dev/models/execution.py @@ -2,15 +2,26 @@ import os from datetime import datetime -from typing import Any +from enum import Enum +from typing import Any, cast from uuid import uuid4 from rich.text import Text +from uipath.core.chat import UiPathConversationEvent, UiPathConversationMessage from uipath.runtime.errors import UiPathErrorContract +from uipath.dev.models.chat import ChatEvents from uipath.dev.models.messages import LogMessage, TraceMessage +class ExecutionMode(Enum): + """Enumeration of execution modes.""" + + RUN = "run" + DEBUG = "debug" + CHAT = "chat" + + class ExecutionRun: """Represents a single execution run.""" @@ -18,15 +29,13 @@ def __init__( self, entrypoint: str, input_data: dict[str, Any], - conversational: bool = False, - debug: bool = False, + mode: ExecutionMode, ): """Initialize an ExecutionRun instance.""" self.id = str(uuid4())[:8] self.entrypoint = entrypoint self.input_data = input_data - self.conversational = conversational - self.debug = debug + self.mode = mode self.resume_data: dict[str, Any] | None = None self.output_data: dict[str, Any] | str | None = None self.start_time = datetime.now() @@ -35,6 +44,7 @@ def __init__( self.traces: list[TraceMessage] = [] self.logs: list[LogMessage] = [] self.error: UiPathErrorContract | None = None + self.chat_events = ChatEvents() @property def duration(self) -> str: @@ -80,3 +90,12 @@ def display_name(self) -> Text: text.append(f"[{duration_str:<6}]") return text + + @property + def messages(self) -> list[UiPathConversationMessage]: + """Get all conversation messages associated with this run.""" + return list(self.chat_events.messages.values()) + + def add_event(self, event: Any) -> UiPathConversationMessage | None: + """Add a conversation event to the run's chat aggregator.""" + return self.chat_events.add(cast(UiPathConversationEvent, event)) diff --git a/src/uipath/dev/models/messages.py b/src/uipath/dev/models/messages.py index b9ad9e1..ef02347 100644 --- a/src/uipath/dev/models/messages.py +++ b/src/uipath/dev/models/messages.py @@ -5,6 +5,7 @@ from rich.console import RenderableType from textual.message import Message +from uipath.core.chat import UiPathConversationEvent, UiPathConversationMessage class LogMessage(Message): @@ -51,3 +52,19 @@ def __init__( self.timestamp = timestamp or datetime.now() self.attributes = attributes or {} super().__init__() + + +class ChatMessage(Message): + """Message sent when a new chat message is created or updated.""" + + def __init__( + self, + event: UiPathConversationEvent | None, + message: UiPathConversationMessage | None, + run_id: str, + ): + """Initialize a ChatMessage instance.""" + self.run_id = run_id + self.event = event + self.message = message + super().__init__() diff --git a/src/uipath/dev/services/run_service.py b/src/uipath/dev/services/run_service.py index b72ea53..089b8b4 100644 --- a/src/uipath/dev/services/run_service.py +++ b/src/uipath/dev/services/run_service.py @@ -5,7 +5,7 @@ import json import traceback from datetime import datetime -from typing import Any, Callable +from typing import Any, Callable, cast from pydantic import BaseModel from uipath.core.tracing import UiPathTraceManager @@ -14,19 +14,28 @@ UiPathExecutionRuntime, UiPathRuntimeFactoryProtocol, UiPathRuntimeProtocol, + UiPathRuntimeResult, UiPathRuntimeStatus, + UiPathStreamOptions, ) from uipath.runtime.debug import UiPathDebugRuntime from uipath.runtime.errors import UiPathErrorContract, UiPathRuntimeError -from uipath.runtime.events import UiPathRuntimeStateEvent +from uipath.runtime.events import UiPathRuntimeMessageEvent, UiPathRuntimeStateEvent from uipath.dev.infrastructure import RunContextExporter, RunContextLogHandler -from uipath.dev.models import ExecutionRun, LogMessage, TraceMessage +from uipath.dev.models import ( + ChatMessage, + ExecutionMode, + ExecutionRun, + LogMessage, + TraceMessage, +) from uipath.dev.services.debug_bridge import TextualDebugBridge RunUpdatedCallback = Callable[[ExecutionRun], None] LogCallback = Callable[[LogMessage], None] TraceCallback = Callable[[TraceMessage], None] +ChatCallback = Callable[[ChatMessage], None] class RunService: @@ -45,6 +54,7 @@ def __init__( on_run_updated: RunUpdatedCallback | None = None, on_log: LogCallback | None = None, on_trace: TraceCallback | None = None, + on_chat: ChatCallback | None = None, ) -> None: """Initialize RunService with runtime factory and trace manager.""" self.runtime_factory = runtime_factory @@ -54,6 +64,7 @@ def __init__( self.on_run_updated = on_run_updated self.on_log = on_log self.on_trace = on_trace + self.on_chat = on_chat self.trace_manager.add_span_exporter( RunContextExporter( @@ -96,7 +107,6 @@ async def execute(self, run: ExecutionRun) -> None: run.start_time = datetime.now() self._emit_run_updated(run) - # Attach log handler that goes back into this service log_handler = RunContextLogHandler( run_id=run.id, callback=self.handle_log, @@ -109,10 +119,9 @@ async def execute(self, run: ExecutionRun) -> None: runtime: UiPathRuntimeProtocol - if run.debug: + if run.mode == ExecutionMode.DEBUG: debug_bridge = TextualDebugBridge() - # Connect callbacks debug_bridge.on_state_update = lambda state: self._handle_state_update( run.id, state ) @@ -126,7 +135,6 @@ async def execute(self, run: ExecutionRun) -> None: run, error ) - # Store bridge so UI can access it self.debug_bridges[run.id] = debug_bridge runtime = UiPathDebugRuntime( @@ -143,7 +151,26 @@ async def execute(self, run: ExecutionRun) -> None: execution_id=run.id, ) - result = await execution_runtime.execute(execution_input, execution_options) + if run.mode == ExecutionMode.CHAT: + result: UiPathRuntimeResult | None = None + async for event in execution_runtime.stream( + execution_input, + options=cast(UiPathStreamOptions, execution_options), + ): + if isinstance(event, UiPathRuntimeResult): + result = event + elif isinstance(event, UiPathRuntimeMessageEvent): + if self.on_chat is not None: + chat_msg = ChatMessage( + event=event.payload, + message=run.add_event(event.payload), + run_id=run.id, + ) + self.on_chat(chat_msg) + else: + result = await execution_runtime.execute( + execution_input, execution_options + ) if result is not None: if ( diff --git a/src/uipath/dev/ui/panels/chat_panel.py b/src/uipath/dev/ui/panels/chat_panel.py new file mode 100644 index 0000000..30d9094 --- /dev/null +++ b/src/uipath/dev/ui/panels/chat_panel.py @@ -0,0 +1,140 @@ +"""Chat panel for displaying and interacting with chat messages.""" + +import time + +from textual.app import ComposeResult +from textual.containers import Container, Vertical, VerticalScroll +from textual.widgets import Input, Markdown +from uipath.core.chat import ( + UiPathExternalValue, + UiPathInlineValue, +) + +from uipath.dev.models import ChatMessage, ExecutionRun + + +class Prompt(Markdown): + """User prompt message bubble.""" + + pass + + +class Response(Markdown): + """AI response message bubble.""" + + BORDER_TITLE = "🤖 ai" + + +class Tool(Markdown): + """Tool message bubble.""" + + BORDER_TITLE = "🛠️ tool" + + +class ChatPanel(Container): + """Panel for displaying and interacting with chat messages.""" + + _chat_widgets: dict[str, Markdown] + _last_update_time: dict[str, float] + + def __init__(self, **kwargs): + """Initialize the chat panel.""" + super().__init__(**kwargs) + self._chat_widgets = {} + self._last_update_time = {} + + def compose(self) -> ComposeResult: + """Compose the UI layout.""" + with Vertical(id="chat-container"): + yield VerticalScroll(id="chat-view") + yield Input( + placeholder="Type your message and press Enter...", + id="chat-input", + ) + + def update_messages(self, run: ExecutionRun) -> None: + """Update the chat panel with messages from the given execution run.""" + chat_view = self.query_one("#chat-view") + chat_view.remove_children() + self._chat_widgets.clear() + self._last_update_time.clear() + + for chat_msg in run.messages: + self.add_chat_message( + ChatMessage(message=chat_msg, event=None, run_id=run.id), + auto_scroll=False, + ) + + chat_view.scroll_end(animate=False) + + def add_chat_message( + self, + chat_msg: ChatMessage, + auto_scroll: bool = True, + ) -> None: + """Add or update a chat message bubble.""" + chat_view = self.query_one("#chat-view") + + message = chat_msg.message + + if message is None: + return + + widget_cls: type[Prompt] | type[Response] | type[Tool] + if message.role == "user": + widget_cls = Prompt + elif message.role == "assistant": + widget_cls = Response + else: + widget_cls = Response + + parts: list[str] = [] + if message.content_parts: + for part in message.content_parts: + if ( + part.mime_type.startswith("text/") + or part.mime_type == "application/json" + ): + if isinstance(part.data, UiPathInlineValue): + parts.append(part.data.inline or "") + elif isinstance(part.data, UiPathExternalValue): + parts.append(f"[external: {part.data.url}]") + + text_block = "\n".join(parts).strip() + content_lines = [f"{text_block}"] if text_block else [] + + if message.tool_calls: + widget_cls = Tool + for call in message.tool_calls: + status_icon = "✓" if call.result else "⚙" + content_lines.append(f" {status_icon} **{call.name}**") + + if not content_lines: + return + + content = "\n\n".join(content_lines) + + existing = self._chat_widgets.get(message.message_id) + now = time.monotonic() + last_update = self._last_update_time.get(message.message_id, 0.0) + + if existing: + event = chat_msg.event + should_update = ( + event + and event.exchange + and event.exchange.message + and event.exchange.message.end is not None + ) + if should_update or now - last_update > 0.15: + existing.update(content) + self._last_update_time[message.message_id] = now + if auto_scroll: + chat_view.scroll_end(animate=False) + else: + widget_instance = widget_cls(content) + chat_view.mount(widget_instance) + self._chat_widgets[message.message_id] = widget_instance + self._last_update_time[message.message_id] = now + if auto_scroll: + chat_view.scroll_end(animate=False) diff --git a/src/uipath/dev/ui/panels/new_run_panel.py b/src/uipath/dev/ui/panels/new_run_panel.py index b4f3a21..07cd53b 100644 --- a/src/uipath/dev/ui/panels/new_run_panel.py +++ b/src/uipath/dev/ui/panels/new_run_panel.py @@ -33,7 +33,6 @@ def __init__( self.entrypoint_schemas: dict[str, dict[str, Any]] = {} - self.conversational: bool = False self.initial_input: str = "{}" def compose(self) -> ComposeResult: @@ -62,11 +61,17 @@ def compose(self) -> ComposeResult: classes="action-btn", ) yield Button( - "⏯ Debug", + "⏸ Debug", id="debug-btn", variant="primary", classes="action-btn", ) + yield Button( + "💬 Chat", + id="chat-btn", + variant="primary", + classes="action-btn", + ) async def on_mount(self) -> None: """Discover entrypoints once, and set the first as default.""" @@ -150,10 +155,10 @@ async def on_select_changed(self, event: Select.Changed) -> None: self.selected_entrypoint = new_entrypoint await self._load_schema_and_update_input(self.selected_entrypoint) - def get_input_values(self) -> Tuple[str, str, bool]: + def get_input_values(self) -> Tuple[str, str]: """Get the selected entrypoint and JSON input values.""" json_input = self.query_one("#json-input", JsonInput) - return self.selected_entrypoint, json_input.text.strip(), self.conversational + return self.selected_entrypoint, json_input.text.strip() def reset_form(self) -> None: """Reset selection and JSON input to defaults.""" diff --git a/src/uipath/dev/ui/panels/run_details_panel.py b/src/uipath/dev/ui/panels/run_details_panel.py index 987bfe8..e3752ed 100644 --- a/src/uipath/dev/ui/panels/run_details_panel.py +++ b/src/uipath/dev/ui/panels/run_details_panel.py @@ -12,8 +12,9 @@ ) from textual.widgets.tree import TreeNode -from uipath.dev.models.execution import ExecutionRun -from uipath.dev.models.messages import LogMessage, TraceMessage +from uipath.dev.models.execution import ExecutionMode, ExecutionRun +from uipath.dev.models.messages import ChatMessage, LogMessage, TraceMessage +from uipath.dev.ui.panels.chat_panel import ChatPanel class SpanDetailsDisplay(Container): @@ -36,9 +37,8 @@ def show_span_details(self, trace_msg: TraceMessage): details_log.write(f"[bold cyan]Span: {trace_msg.span_name}[/bold cyan]") - details_log.write("") # Empty line + details_log.write("") - # Status with color color_map = { "started": "blue", "running": "yellow", @@ -49,7 +49,6 @@ def show_span_details(self, trace_msg: TraceMessage): color = color_map.get(trace_msg.status.lower(), "white") details_log.write(f"Status: [{color}]{trace_msg.status.upper()}[/{color}]") - # Timestamps details_log.write( f"Started: [dim]{trace_msg.timestamp.strftime('%H:%M:%S.%f')[:-3]}[/dim]" ) @@ -59,16 +58,14 @@ def show_span_details(self, trace_msg: TraceMessage): f"Duration: [yellow]{trace_msg.duration_ms:.2f}ms[/yellow]" ) - # Additional attributes if available if trace_msg.attributes: details_log.write("") details_log.write("[bold]Attributes:[/bold]") for key, value in trace_msg.attributes.items(): details_log.write(f" {key}: {value}") - details_log.write("") # Empty line + details_log.write("") - # Format span details details_log.write(f"[dim]Trace ID: {trace_msg.trace_id}[/dim]") details_log.write(f"[dim]Span ID: {trace_msg.span_id}[/dim]") details_log.write(f"[dim]Run ID: {trace_msg.run_id}[/dim]") @@ -85,13 +82,12 @@ class RunDetailsPanel(Container): def __init__(self, **kwargs): """Initialize RunDetailsPanel.""" super().__init__(**kwargs) - self.span_tree_nodes = {} # Map span_id to tree nodes - self.current_run = None # Store reference to current run + self.span_tree_nodes = {} + self.current_run = None def compose(self) -> ComposeResult: """Compose the UI layout.""" with TabbedContent(): - # Run details tab with TabPane("Details", id="run-tab"): yield RichLog( id="run-details-log", @@ -101,7 +97,6 @@ def compose(self) -> ComposeResult: classes="detail-log", ) - # Traces tab with TabPane("Traces", id="traces-tab"): with Horizontal(classes="traces-content"): # Left side - Span tree @@ -114,7 +109,6 @@ def compose(self) -> ComposeResult: with Vertical(classes="span-details-section"): yield SpanDetailsDisplay(id="span-details-display") - # Logs tab with TabPane("Logs", id="logs-tab"): yield RichLog( id="logs-log", @@ -123,6 +117,10 @@ def compose(self) -> ComposeResult: markup=True, classes="detail-log", ) + + with TabPane("Chat", id="chat-tab"): + yield ChatPanel(id="chat-panel") + # Global debug controls (hidden by default, shown when debug mode active) with Container(id="debug-controls", classes="debug-controls hidden"): with Horizontal(classes="debug-actions-row"): @@ -157,16 +155,13 @@ def update_run(self, run: ExecutionRun): def show_run(self, run: ExecutionRun): """Display traces and logs for a specific run.""" - # Populate run details tab self._show_run_details(run) - # Populate logs - convert string logs to display format logs_log = self.query_one("#logs-log", RichLog) logs_log.clear() for log in run.logs: self.add_log(log) - # Clear and rebuild traces tree using TraceMessage objects self._rebuild_spans_tree() def switch_tab(self, tab_id: str) -> None: @@ -177,7 +172,7 @@ def switch_tab(self, tab_id: str) -> None: def update_debug_controls_visibility(self, run: ExecutionRun): """Show or hide debug controls based on whether run is in debug mode.""" debug_controls = self.query_one("#debug-controls", Container) - if run.debug: + if run.mode == ExecutionMode.DEBUG: debug_controls.remove_class("hidden") is_enabled = run.status == "suspended" for button in debug_controls.query(Button): @@ -244,11 +239,9 @@ def _show_run_details(self, run: ExecutionRun): run_details_log = self.query_one("#run-details-log", RichLog) run_details_log.clear() - # Run header run_details_log.write(f"[bold cyan]Run ID: {run.id}[/bold cyan]") run_details_log.write("") - # Run status with color status_color_map = { "started": "blue", "running": "yellow", @@ -262,7 +255,6 @@ def _show_run_details(self, run: ExecutionRun): f"[bold]Status:[/bold] [{color}]{status.upper()}[/{color}]" ) - # Timestamps if hasattr(run, "start_time") and run.start_time: run_details_log.write( f"[bold]Started:[/bold] [dim]{run.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]" @@ -273,7 +265,6 @@ def _show_run_details(self, run: ExecutionRun): f"[bold]Ended:[/bold] [dim]{run.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}[/dim]" ) - # Duration if hasattr(run, "duration_ms") and run.duration_ms is not None: run_details_log.write( f"[bold]Duration:[/bold] [yellow]{run.duration_ms:.2f}ms[/yellow]" @@ -302,7 +293,6 @@ def _show_run_details(self, run: ExecutionRun): run_details_log, "Output", run.output_data, style="magenta" ) - # Error section (if applicable) if hasattr(run, "error") and run.error: run_details_log.write("[bold red]ERROR:[/bold red]") run_details_log.write("[dim]" + "=" * 50 + "[/dim]") @@ -320,13 +310,11 @@ def _rebuild_spans_tree(self): spans_tree.root.remove_children() - # Only clear the node mapping since we're rebuilding the tree structure self.span_tree_nodes.clear() if not self.current_run or not self.current_run.traces: return - # Build spans tree from TraceMessage objects self._build_spans_tree(self.current_run.traces) # Expand the root "Trace" node @@ -368,7 +356,6 @@ def _add_span_with_children( children_by_parent: dict[str, list[TraceMessage]], ): """Recursively add a span and all its children.""" - # Create the node for this span color_map = { "started": "🔵", "running": "🟡", @@ -411,7 +398,6 @@ def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None: break if trace_msg: - # Show span details span_details_display = self.query_one( "#span-details-display", SpanDetailsDisplay ) @@ -424,6 +410,16 @@ def update_run_details(self, run: ExecutionRun): self._show_run_details(run) + def add_chat_message( + self, + chat_msg: ChatMessage, + ) -> None: + """Add a chat message to the display.""" + if not self.current_run or chat_msg.run_id != self.current_run.id: + return + chat_panel = self.query_one("#chat-panel", ChatPanel) + chat_panel.add_chat_message(chat_msg) + def add_trace(self, trace_msg: TraceMessage): """Add trace to current run if it matches.""" if not self.current_run or trace_msg.run_id != self.current_run.id: @@ -474,7 +470,6 @@ def clear_display(self): self.current_run = None self.span_tree_nodes.clear() - # Clear span details span_details_display = self.query_one( "#span-details-display", SpanDetailsDisplay ) diff --git a/src/uipath/dev/ui/styles/terminal.tcss b/src/uipath/dev/ui/styles/terminal.tcss index f56ab81..cae2b2b 100644 --- a/src/uipath/dev/ui/styles/terminal.tcss +++ b/src/uipath/dev/ui/styles/terminal.tcss @@ -278,4 +278,28 @@ Checkbox{ align: left middle; } +Prompt { + border: wide $primary-background; + background: $surface; + color: $text; + margin-right: 8; + margin-left: 1; + padding: 1 1 0 1; +} +Response, Tool { + border: wide $primary-background; + background: $surface; + color: $text; + margin: 1; + margin-left: 8; + padding: 1 1 0 1; +} + +#chat-container{ + background: $surface; +} +#chat-input{ + dock: bottom; + margin: 1; +} diff --git a/uv.lock b/uv.lock index 1ea704c..5d0dd78 100644 --- a/uv.lock +++ b/uv.lock @@ -988,16 +988,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.0.5" +version = "0.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/be/575d6075bffb70de8f91feeef873d61d8e4051ac66edd97e14d639599ba4/uipath_core-0.0.5.tar.gz", hash = "sha256:b52e7455a45a1af504b6aa12844f14bd6d5bb69c98399b54c962723f5b0cacad", size = 77751, upload-time = "2025-11-24T06:48:40.943Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/79/b7be77e8dda5e5f74b353ed42e5e3fdea37e40f43bb5b06c11b4e9ea2a48/uipath_core-0.0.6.tar.gz", hash = "sha256:a097307b2101d49b1cb554d5808b6ca573640d6a0fe2bada971eb4ff860a648d", size = 81867, upload-time = "2025-12-02T09:30:56.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/ae/9a2488196b98f0f9442573cdbfa146a2ae614aec22dabc28c9b34b4abf45/uipath_core-0.0.5-py3-none-any.whl", hash = "sha256:056b8f2fede1e23717ad0c356a300466ac8ca41dd03b9a5fecb6ab24dd5dc97e", size = 13183, upload-time = "2025-11-24T06:48:39.717Z" }, + { url = "https://files.pythonhosted.org/packages/09/4d/796f6ed63ba64c3b38e6763446c0c8ae9896ef443318a4d554a5dfcf447c/uipath_core-0.0.6-py3-none-any.whl", hash = "sha256:5bdd42b752659779774998ceffaf2672365d40dc8e4290a77cfd50f4e3504113", size = 21392, upload-time = "2025-12-02T09:30:54.899Z" }, ] [[package]] @@ -1007,6 +1007,7 @@ source = { editable = "." } dependencies = [ { name = "pyperclip" }, { name = "textual" }, + { name = "uipath-core" }, { name = "uipath-runtime" }, ] @@ -1029,7 +1030,8 @@ dev = [ requires-dist = [ { name = "pyperclip", specifier = ">=1.11.0,<2.0.0" }, { name = "textual", specifier = ">=6.6.0,<7.0.0" }, - { name = "uipath-runtime", specifier = ">=0.1.0,<0.2.0" }, + { name = "uipath-core", specifier = ">=0.0.6,<0.1.0" }, + { name = "uipath-runtime", specifier = ">=0.1.2,<0.2.0" }, ] [package.metadata.requires-dev] @@ -1049,14 +1051,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.1.0" +version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/5f/b48eaa87501ccffb067878ff99773fa89fbc1cafa8ab736d8fd5ae099b59/uipath_runtime-0.1.0.tar.gz", hash = "sha256:2a262eb29faeb1d62158ccaf1d1ec44752813625fdbcab5671a625c999c433ac", size = 87980, upload-time = "2025-11-29T13:10:32.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/620a024de7ea2797c441e7b4b45c7e4f596ecf207bc574ed2c35dceca562/uipath_runtime-0.1.2.tar.gz", hash = "sha256:ab322be3d25eb27eb5112837fc29fbe49d98c6cd71de297b8e0f3218fac1cf7d", size = 88027, upload-time = "2025-12-01T07:32:06.299Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/2a/39364e985269ac27b6b78d0323e6f516e25287bca87938a2088442b5cf06/uipath_runtime-0.1.0-py3-none-any.whl", hash = "sha256:997b53737fc6f22bb2e80700fd45c6b0a7912af6843fd4a103c58bdcf30f7fdd", size = 34207, upload-time = "2025-11-29T13:10:31.127Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ef/7f205944f542e66eab981bc0cd02f6b14e08f374c36f10ec90873b376eda/uipath_runtime-0.1.2-py3-none-any.whl", hash = "sha256:9ca7e08cf2b9c1ca32e53c2f293abb5568bfab187a6fe4581a12069feb689124", size = 34268, upload-time = "2025-12-01T07:32:04.806Z" }, ] [[package]]