diff --git a/pyproject.toml b/pyproject.toml index f428ba7..f3f7497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.42" +version = "0.0.43" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,6 +8,8 @@ dependencies = [ "uipath-runtime>=0.8.2, <0.9.0", "textual>=7.5.0, <8.0.0", "pyperclip>=1.11.0, <2.0.0", + "fastapi>=0.128.8", + "uvicorn[standard]>=0.40.0", ] classifiers = [ "Intended Audience :: Developers", @@ -21,12 +23,6 @@ maintainers = [ { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" }, ] -[project.optional-dependencies] -server = [ - "fastapi>=0.128.8", - "uvicorn[standard]>=0.40.0", -] - [project.urls] Homepage = "https://uipath.com" Repository = "https://github.com/UiPath/uipath-dev-python" diff --git a/src/uipath/dev/__mock_server__.py b/src/uipath/dev/__mock_server__.py index 476033e..c18c5d3 100644 --- a/src/uipath/dev/__mock_server__.py +++ b/src/uipath/dev/__mock_server__.py @@ -32,7 +32,7 @@ def main(): server.run() except ImportError as e: print(f"Required dependencies not available: {e}") - print("Install server dependencies: pip install uipath-dev[server]") + print("Install server dependencies: pip install uipath-dev") if __name__ == "__main__": diff --git a/src/uipath/dev/models/data.py b/src/uipath/dev/models/data.py index 529cb95..83fdc7b 100644 --- a/src/uipath/dev/models/data.py +++ b/src/uipath/dev/models/data.py @@ -50,3 +50,17 @@ class ChatData: run_id: str event: UiPathConversationMessageEvent | None = None message: UiPathConversationMessage | None = None + + +@dataclass +class InterruptData: + """Plain data class for HITL interrupt events.""" + + run_id: str + interrupt_id: str + interrupt_type: str # "tool_call_confirmation" | "generic" + tool_call_id: str | None = None + tool_name: str | None = None + input_schema: Any | None = None + input_value: Any | None = None + content: Any | None = None diff --git a/src/uipath/dev/server/__init__.py b/src/uipath/dev/server/__init__.py index 3974936..1bdfbd7 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -13,28 +13,23 @@ from collections.abc import Callable from typing import Any +import uvicorn from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeFactoryProtocol -from uipath.dev.models.data import ChatData, LogData, StateData, TraceData +from uipath.dev.models.data import ( + ChatData, + InterruptData, + LogData, + StateData, + TraceData, +) from uipath.dev.models.execution import ExecutionRun from uipath.dev.server.debug_bridge import WebDebugBridge from uipath.dev.services.run_service import RunService logger = logging.getLogger(__name__) -try: - import fastapi # noqa: F401 - import uvicorn # noqa: F401 - - HAS_EXTRAS = True -except ModuleNotFoundError: - HAS_EXTRAS = False - -_MISSING_EXTRAS_MSG = ( - "Server extras are not installed. Install them with: pip install uipath-dev[server]" -) - class UiPathDeveloperServer: """Web server mode for the UiPath Developer Console. @@ -86,14 +81,12 @@ def __init__( on_trace=self._on_trace, on_chat=self._on_chat, on_state=self._on_state, + on_interrupt=self._on_interrupt, debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode), ) def create_app(self) -> Any: """Create and return a FastAPI application.""" - if not HAS_EXTRAS: - raise ImportError(_MISSING_EXTRAS_MSG) - from uipath.dev.server.app import create_app return create_app(self) @@ -104,9 +97,6 @@ async def run_async(self) -> None: This is the main entry point — mirrors UiPathDeveloperConsole.run_async(). Blocks until the server is shut down (Ctrl-C / SIGINT). """ - if not HAS_EXTRAS: - raise ImportError(_MISSING_EXTRAS_MSG) - await self.run_service.apply_factory_settings() self.port = self._find_free_port(self.host, self.port) app = self.create_app() @@ -232,6 +222,10 @@ def _on_chat(self, chat_data: ChatData) -> None: """Broadcast chat message to subscribed WebSocket clients.""" self.connection_manager.broadcast_chat(chat_data) + def _on_interrupt(self, interrupt_data: InterruptData) -> None: + """Broadcast chat interrupt to subscribed WebSocket clients.""" + self.connection_manager.broadcast_interrupt(interrupt_data) + def _on_state(self, state_data: StateData) -> None: """Broadcast state transition to subscribed WebSocket clients.""" self.connection_manager.broadcast_state(state_data) @@ -259,6 +253,11 @@ def _print_banner(base_url: str) -> None: """Print a welcome banner to the console.""" import sys + from rich.console import Console + from rich.text import Text + + console = Console() + # Use emojis only if stdout supports unicode (not Windows cp1252) try: "\U0001f916".encode(sys.stdout.encoding or "utf-8") @@ -266,20 +265,31 @@ def _print_banner(base_url: str) -> None: except (UnicodeEncodeError, LookupError): server_icon, docs_icon = ">>", ">>" - banner = ( - "\n" - " _ _ _ ____ _ _ ____\n" - "| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __\n" - "| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /\n" - "| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /\n" - " \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/\n" - "\n" - f" {server_icon} Server: {base_url}\n" - f" {docs_icon} Docs: https://uipath.github.io/uipath-python/\n" - "\n" - " This server is designed for development and testing.\n" + art_lines = [ + " _ _ _ ____ _ _ ____", + "| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __", + "| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /", + "| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /", + " \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/", + ] + + console.print() + for line in art_lines: + styled = Text(line) + styled.stylize("bold orange1") + console.print(styled) + console.print() + + console.print(f" {server_icon} Server: [bold cyan]{base_url}[/bold cyan]") + console.print( + f" {docs_icon} Docs: [link=https://uipath.github.io/uipath-python/]" + "https://uipath.github.io/uipath-python/[/link]" + ) + console.print() + console.print( + " [dim]This server is designed for development and testing.[/dim]" ) - print(banner) + console.print() def _deferred_open_browser(self) -> None: """Open the browser after a short delay to let uvicorn bind.""" diff --git a/src/uipath/dev/server/app.py b/src/uipath/dev/server/app.py index 1e065ba..542582c 100644 --- a/src/uipath/dev/server/app.py +++ b/src/uipath/dev/server/app.py @@ -4,14 +4,12 @@ import logging from pathlib import Path -from typing import TYPE_CHECKING from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, Response -if TYPE_CHECKING: - from uipath.dev.server import UiPathDeveloperServer +from uipath.dev.server import UiPathDeveloperServer logger = logging.getLogger(__name__) @@ -75,7 +73,7 @@ def _fallback_html() -> str: "The frontend source directory was not found. " "If you installed from PyPI, the pre-built static files should " "be included. Try reinstalling with " - "pip install uipath-dev[server]." + "pip install uipath-dev." ), ) diff --git a/src/uipath/dev/server/frontend/src/api/websocket.ts b/src/uipath/dev/server/frontend/src/api/websocket.ts index 58d3e53..1621071 100644 --- a/src/uipath/dev/server/frontend/src/api/websocket.ts +++ b/src/uipath/dev/server/frontend/src/api/websocket.ts @@ -96,6 +96,10 @@ export class WsClient { this.send("chat.message", { run_id: runId, text }); } + sendInterruptResponse(runId: string, data: Record): void { + this.send("chat.interrupt_response", { run_id: runId, data }); + } + debugStep(runId: string): void { this.send("debug.step", { run_id: runId }); } diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx new file mode 100644 index 0000000..d82186a --- /dev/null +++ b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import type { InterruptEvent } from "../../types/run"; + +interface Props { + interrupt: InterruptEvent; + onRespond: (data: Record) => void; +} + +export default function ChatInterrupt({ interrupt, onRespond }: Props) { + const [responseText, setResponseText] = useState(""); + + if (interrupt.interrupt_type === "tool_call_confirmation") { + return ( +
+
+ + Action Required + + {interrupt.tool_name && ( + + {interrupt.tool_name} + + )} +
+ {interrupt.input_value != null && ( +
+            {typeof interrupt.input_value === "string"
+              ? interrupt.input_value
+              : JSON.stringify(interrupt.input_value, null, 2)}
+          
+ )} +
+ + +
+
+ ); + } + + // Generic interrupt + return ( +
+
+ + Input Required + +
+ {interrupt.content != null && ( +
+ {typeof interrupt.content === "string" + ? interrupt.content + : JSON.stringify(interrupt.content, null, 2)} +
+ )} +
+ setResponseText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && responseText.trim()) { + e.preventDefault(); + onRespond({ response: responseText.trim() }); + } + }} + placeholder="Type your response..." + className="flex-1 bg-transparent text-xs py-1 focus:outline-none placeholder:text-[var(--text-muted)]" + style={{ color: "var(--text-primary)" }} + /> + +
+
+ ); +} diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx index bee1884..24e14ee 100644 --- a/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx @@ -3,6 +3,7 @@ import type { WsClient } from "../../api/websocket"; import { useRunStore } from "../../store/useRunStore"; import ChatMessage from "./ChatMessage"; import ChatInput from "./ChatInput"; +import ChatInterrupt from "./ChatInterrupt"; interface ChatMsg { message_id: string; @@ -23,6 +24,8 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { const stickToBottom = useRef(true); const addLocalChatMessage = useRunStore((s) => s.addLocalChatMessage); const setFocusedSpan = useRunStore((s) => s.setFocusedSpan); + const interrupt = useRunStore((s) => s.activeInterrupt[runId] ?? null); + const setActiveInterrupt = useRunStore((s) => s.setActiveInterrupt); // Precompute per-tool-call occurrence indices across all messages const toolCallIndicesMap = useMemo(() => { @@ -71,7 +74,13 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { ws.sendChatMessage(runId, text); }; - const isDisabled = runStatus === "running"; + const handleInterruptResponse = (data: Record) => { + stickToBottom.current = true; + ws.sendInterruptResponse(runId, data); + setActiveInterrupt(runId, null); + }; + + const isDisabled = runStatus === "running" || !!interrupt; return (
@@ -94,6 +103,12 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { onToolCallClick={(name, idx) => setFocusedSpan({ name, index: idx })} /> ))} + {interrupt && ( + + )}
{showScrollTop && (