Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
[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"
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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/uipath/dev/__mock_server__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
14 changes: 14 additions & 0 deletions src/uipath/dev/models/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 42 additions & 32 deletions src/uipath/dev/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -259,27 +253,43 @@ 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")
server_icon, docs_icon = "\U0001f916", "\U0001f4da"
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."""
Expand Down
6 changes: 2 additions & 4 deletions src/uipath/dev/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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 "
"<code>pip install uipath-dev[server]</code>."
"<code>pip install uipath-dev</code>."
),
)

Expand Down
4 changes: 4 additions & 0 deletions src/uipath/dev/server/frontend/src/api/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export class WsClient {
this.send("chat.message", { run_id: runId, text });
}

sendInterruptResponse(runId: string, data: Record<string, unknown>): void {
this.send("chat.interrupt_response", { run_id: runId, data });
}

debugStep(runId: string): void {
this.send("debug.step", { run_id: runId });
}
Expand Down
172 changes: 172 additions & 0 deletions src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useState } from "react";
import type { InterruptEvent } from "../../types/run";

interface Props {
interrupt: InterruptEvent;
onRespond: (data: Record<string, unknown>) => void;
}

export default function ChatInterrupt({ interrupt, onRespond }: Props) {
const [responseText, setResponseText] = useState("");

if (interrupt.interrupt_type === "tool_call_confirmation") {
return (
<div
className="mx-3 my-2 rounded-lg overflow-hidden"
style={{ border: "1px solid color-mix(in srgb, var(--warning) 40%, var(--border))" }}
>
<div
className="px-3 py-2 flex items-center gap-2"
style={{
background: "color-mix(in srgb, var(--warning) 10%, var(--bg-secondary))",
}}
>
<span
className="text-[10px] uppercase tracking-wider font-semibold"
style={{ color: "var(--warning)" }}
>
Action Required
</span>
{interrupt.tool_name && (
<span
className="text-[10px] font-mono px-1.5 py-0.5 rounded"
style={{
background: "color-mix(in srgb, var(--warning) 15%, var(--bg-secondary))",
color: "var(--text-primary)",
}}
>
{interrupt.tool_name}
</span>
)}
</div>
{interrupt.input_value != null && (
<pre
className="px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words overflow-y-auto"
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
maxHeight: 200,
}}
>
{typeof interrupt.input_value === "string"
? interrupt.input_value
: JSON.stringify(interrupt.input_value, null, 2)}
</pre>
)}
<div
className="flex items-center gap-2 px-3 py-2"
style={{
background: "var(--bg-secondary)",
borderTop: "1px solid var(--border)",
}}
>
<button
onClick={() => onRespond({ approved: true })}
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
style={{
background: "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))",
color: "var(--success)",
border: "1px solid color-mix(in srgb, var(--success) 30%, var(--border))",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 25%, var(--bg-secondary))";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))";
}}
>
Approve
</button>
<button
onClick={() => onRespond({ approved: false })}
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
style={{
background: "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))",
color: "var(--error)",
border: "1px solid color-mix(in srgb, var(--error) 30%, var(--border))",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 25%, var(--bg-secondary))";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))";
}}
>
Reject
</button>
</div>
</div>
);
}

// Generic interrupt
return (
<div
className="mx-3 my-2 rounded-lg overflow-hidden"
style={{ border: "1px solid color-mix(in srgb, var(--accent) 40%, var(--border))" }}
>
<div
className="px-3 py-2"
style={{
background: "color-mix(in srgb, var(--accent) 10%, var(--bg-secondary))",
}}
>
<span
className="text-[10px] uppercase tracking-wider font-semibold"
style={{ color: "var(--accent)" }}
>
Input Required
</span>
</div>
{interrupt.content != null && (
<div
className="px-3 py-2 text-xs"
style={{
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
{typeof interrupt.content === "string"
? interrupt.content
: JSON.stringify(interrupt.content, null, 2)}
</div>
)}
<div
className="flex items-center gap-2 px-3 py-2"
style={{
background: "var(--bg-secondary)",
borderTop: "1px solid var(--border)",
}}
>
<input
value={responseText}
onChange={(e) => 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)" }}
/>
<button
onClick={() => {
if (responseText.trim()) {
onRespond({ response: responseText.trim() });
}
}}
disabled={!responseText.trim()}
className="text-[10px] uppercase tracking-wider font-semibold px-2 py-1 rounded transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
style={{
color: responseText.trim() ? "var(--accent)" : "var(--text-muted)",
background: "transparent",
}}
>
Send
</button>
</div>
</div>
);
}
Loading