Skip to content

Commit 49a7cc3

Browse files
authored
Merge pull request #62 from UiPath/feat/rich-banner
feat: Rich banner, HITL interrupts, server deps & edge overlap fix
2 parents 083829f + 699b468 commit 49a7cc3

28 files changed

Lines changed: 633 additions & 205 deletions

pyproject.toml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.42"
3+
version = "0.0.43"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath-runtime>=0.8.2, <0.9.0",
99
"textual>=7.5.0, <8.0.0",
1010
"pyperclip>=1.11.0, <2.0.0",
11+
"fastapi>=0.128.8",
12+
"uvicorn[standard]>=0.40.0",
1113
]
1214
classifiers = [
1315
"Intended Audience :: Developers",
@@ -21,12 +23,6 @@ maintainers = [
2123
{ name = "Cristian Pufu", email = "cristian.pufu@uipath.com" },
2224
]
2325

24-
[project.optional-dependencies]
25-
server = [
26-
"fastapi>=0.128.8",
27-
"uvicorn[standard]>=0.40.0",
28-
]
29-
3026
[project.urls]
3127
Homepage = "https://uipath.com"
3228
Repository = "https://github.com/UiPath/uipath-dev-python"

src/uipath/dev/__mock_server__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def main():
3232
server.run()
3333
except ImportError as e:
3434
print(f"Required dependencies not available: {e}")
35-
print("Install server dependencies: pip install uipath-dev[server]")
35+
print("Install server dependencies: pip install uipath-dev")
3636

3737

3838
if __name__ == "__main__":

src/uipath/dev/models/data.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,17 @@ class ChatData:
5050
run_id: str
5151
event: UiPathConversationMessageEvent | None = None
5252
message: UiPathConversationMessage | None = None
53+
54+
55+
@dataclass
56+
class InterruptData:
57+
"""Plain data class for HITL interrupt events."""
58+
59+
run_id: str
60+
interrupt_id: str
61+
interrupt_type: str # "tool_call_confirmation" | "generic"
62+
tool_call_id: str | None = None
63+
tool_name: str | None = None
64+
input_schema: Any | None = None
65+
input_value: Any | None = None
66+
content: Any | None = None

src/uipath/dev/server/__init__.py

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,23 @@
1313
from collections.abc import Callable
1414
from typing import Any
1515

16+
import uvicorn
1617
from uipath.core.tracing import UiPathTraceManager
1718
from uipath.runtime import UiPathRuntimeFactoryProtocol
1819

19-
from uipath.dev.models.data import ChatData, LogData, StateData, TraceData
20+
from uipath.dev.models.data import (
21+
ChatData,
22+
InterruptData,
23+
LogData,
24+
StateData,
25+
TraceData,
26+
)
2027
from uipath.dev.models.execution import ExecutionRun
2128
from uipath.dev.server.debug_bridge import WebDebugBridge
2229
from uipath.dev.services.run_service import RunService
2330

2431
logger = logging.getLogger(__name__)
2532

26-
try:
27-
import fastapi # noqa: F401
28-
import uvicorn # noqa: F401
29-
30-
HAS_EXTRAS = True
31-
except ModuleNotFoundError:
32-
HAS_EXTRAS = False
33-
34-
_MISSING_EXTRAS_MSG = (
35-
"Server extras are not installed. Install them with: pip install uipath-dev[server]"
36-
)
37-
3833

3934
class UiPathDeveloperServer:
4035
"""Web server mode for the UiPath Developer Console.
@@ -86,14 +81,12 @@ def __init__(
8681
on_trace=self._on_trace,
8782
on_chat=self._on_chat,
8883
on_state=self._on_state,
84+
on_interrupt=self._on_interrupt,
8985
debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode),
9086
)
9187

9288
def create_app(self) -> Any:
9389
"""Create and return a FastAPI application."""
94-
if not HAS_EXTRAS:
95-
raise ImportError(_MISSING_EXTRAS_MSG)
96-
9790
from uipath.dev.server.app import create_app
9891

9992
return create_app(self)
@@ -104,9 +97,6 @@ async def run_async(self) -> None:
10497
This is the main entry point — mirrors UiPathDeveloperConsole.run_async().
10598
Blocks until the server is shut down (Ctrl-C / SIGINT).
10699
"""
107-
if not HAS_EXTRAS:
108-
raise ImportError(_MISSING_EXTRAS_MSG)
109-
110100
await self.run_service.apply_factory_settings()
111101
self.port = self._find_free_port(self.host, self.port)
112102
app = self.create_app()
@@ -232,6 +222,10 @@ def _on_chat(self, chat_data: ChatData) -> None:
232222
"""Broadcast chat message to subscribed WebSocket clients."""
233223
self.connection_manager.broadcast_chat(chat_data)
234224

225+
def _on_interrupt(self, interrupt_data: InterruptData) -> None:
226+
"""Broadcast chat interrupt to subscribed WebSocket clients."""
227+
self.connection_manager.broadcast_interrupt(interrupt_data)
228+
235229
def _on_state(self, state_data: StateData) -> None:
236230
"""Broadcast state transition to subscribed WebSocket clients."""
237231
self.connection_manager.broadcast_state(state_data)
@@ -259,27 +253,43 @@ def _print_banner(base_url: str) -> None:
259253
"""Print a welcome banner to the console."""
260254
import sys
261255

256+
from rich.console import Console
257+
from rich.text import Text
258+
259+
console = Console()
260+
262261
# Use emojis only if stdout supports unicode (not Windows cp1252)
263262
try:
264263
"\U0001f916".encode(sys.stdout.encoding or "utf-8")
265264
server_icon, docs_icon = "\U0001f916", "\U0001f4da"
266265
except (UnicodeEncodeError, LookupError):
267266
server_icon, docs_icon = ">>", ">>"
268267

269-
banner = (
270-
"\n"
271-
" _ _ _ ____ _ _ ____\n"
272-
"| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __\n"
273-
"| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /\n"
274-
"| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /\n"
275-
" \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/\n"
276-
"\n"
277-
f" {server_icon} Server: {base_url}\n"
278-
f" {docs_icon} Docs: https://uipath.github.io/uipath-python/\n"
279-
"\n"
280-
" This server is designed for development and testing.\n"
268+
art_lines = [
269+
" _ _ _ ____ _ _ ____",
270+
"| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __",
271+
"| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /",
272+
"| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /",
273+
" \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/",
274+
]
275+
276+
console.print()
277+
for line in art_lines:
278+
styled = Text(line)
279+
styled.stylize("bold orange1")
280+
console.print(styled)
281+
console.print()
282+
283+
console.print(f" {server_icon} Server: [bold cyan]{base_url}[/bold cyan]")
284+
console.print(
285+
f" {docs_icon} Docs: [link=https://uipath.github.io/uipath-python/]"
286+
"https://uipath.github.io/uipath-python/[/link]"
287+
)
288+
console.print()
289+
console.print(
290+
" [dim]This server is designed for development and testing.[/dim]"
281291
)
282-
print(banner)
292+
console.print()
283293

284294
def _deferred_open_browser(self) -> None:
285295
"""Open the browser after a short delay to let uvicorn bind."""

src/uipath/dev/server/app.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@
44

55
import logging
66
from pathlib import Path
7-
from typing import TYPE_CHECKING
87

98
from fastapi import FastAPI
109
from fastapi.middleware.cors import CORSMiddleware
1110
from fastapi.responses import HTMLResponse, Response
1211

13-
if TYPE_CHECKING:
14-
from uipath.dev.server import UiPathDeveloperServer
12+
from uipath.dev.server import UiPathDeveloperServer
1513

1614
logger = logging.getLogger(__name__)
1715

@@ -75,7 +73,7 @@ def _fallback_html() -> str:
7573
"The frontend source directory was not found. "
7674
"If you installed from PyPI, the pre-built static files should "
7775
"be included. Try reinstalling with "
78-
"<code>pip install uipath-dev[server]</code>."
76+
"<code>pip install uipath-dev</code>."
7977
),
8078
)
8179

src/uipath/dev/server/frontend/src/api/websocket.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export class WsClient {
9696
this.send("chat.message", { run_id: runId, text });
9797
}
9898

99+
sendInterruptResponse(runId: string, data: Record<string, unknown>): void {
100+
this.send("chat.interrupt_response", { run_id: runId, data });
101+
}
102+
99103
debugStep(runId: string): void {
100104
this.send("debug.step", { run_id: runId });
101105
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { useState } from "react";
2+
import type { InterruptEvent } from "../../types/run";
3+
4+
interface Props {
5+
interrupt: InterruptEvent;
6+
onRespond: (data: Record<string, unknown>) => void;
7+
}
8+
9+
export default function ChatInterrupt({ interrupt, onRespond }: Props) {
10+
const [responseText, setResponseText] = useState("");
11+
12+
if (interrupt.interrupt_type === "tool_call_confirmation") {
13+
return (
14+
<div
15+
className="mx-3 my-2 rounded-lg overflow-hidden"
16+
style={{ border: "1px solid color-mix(in srgb, var(--warning) 40%, var(--border))" }}
17+
>
18+
<div
19+
className="px-3 py-2 flex items-center gap-2"
20+
style={{
21+
background: "color-mix(in srgb, var(--warning) 10%, var(--bg-secondary))",
22+
}}
23+
>
24+
<span
25+
className="text-[10px] uppercase tracking-wider font-semibold"
26+
style={{ color: "var(--warning)" }}
27+
>
28+
Action Required
29+
</span>
30+
{interrupt.tool_name && (
31+
<span
32+
className="text-[10px] font-mono px-1.5 py-0.5 rounded"
33+
style={{
34+
background: "color-mix(in srgb, var(--warning) 15%, var(--bg-secondary))",
35+
color: "var(--text-primary)",
36+
}}
37+
>
38+
{interrupt.tool_name}
39+
</span>
40+
)}
41+
</div>
42+
{interrupt.input_value != null && (
43+
<pre
44+
className="px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words overflow-y-auto"
45+
style={{
46+
background: "var(--bg-secondary)",
47+
color: "var(--text-secondary)",
48+
maxHeight: 200,
49+
}}
50+
>
51+
{typeof interrupt.input_value === "string"
52+
? interrupt.input_value
53+
: JSON.stringify(interrupt.input_value, null, 2)}
54+
</pre>
55+
)}
56+
<div
57+
className="flex items-center gap-2 px-3 py-2"
58+
style={{
59+
background: "var(--bg-secondary)",
60+
borderTop: "1px solid var(--border)",
61+
}}
62+
>
63+
<button
64+
onClick={() => onRespond({ approved: true })}
65+
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
66+
style={{
67+
background: "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))",
68+
color: "var(--success)",
69+
border: "1px solid color-mix(in srgb, var(--success) 30%, var(--border))",
70+
}}
71+
onMouseEnter={(e) => {
72+
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 25%, var(--bg-secondary))";
73+
}}
74+
onMouseLeave={(e) => {
75+
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))";
76+
}}
77+
>
78+
Approve
79+
</button>
80+
<button
81+
onClick={() => onRespond({ approved: false })}
82+
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
83+
style={{
84+
background: "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))",
85+
color: "var(--error)",
86+
border: "1px solid color-mix(in srgb, var(--error) 30%, var(--border))",
87+
}}
88+
onMouseEnter={(e) => {
89+
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 25%, var(--bg-secondary))";
90+
}}
91+
onMouseLeave={(e) => {
92+
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))";
93+
}}
94+
>
95+
Reject
96+
</button>
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
// Generic interrupt
103+
return (
104+
<div
105+
className="mx-3 my-2 rounded-lg overflow-hidden"
106+
style={{ border: "1px solid color-mix(in srgb, var(--accent) 40%, var(--border))" }}
107+
>
108+
<div
109+
className="px-3 py-2"
110+
style={{
111+
background: "color-mix(in srgb, var(--accent) 10%, var(--bg-secondary))",
112+
}}
113+
>
114+
<span
115+
className="text-[10px] uppercase tracking-wider font-semibold"
116+
style={{ color: "var(--accent)" }}
117+
>
118+
Input Required
119+
</span>
120+
</div>
121+
{interrupt.content != null && (
122+
<div
123+
className="px-3 py-2 text-xs"
124+
style={{
125+
background: "var(--bg-secondary)",
126+
color: "var(--text-secondary)",
127+
}}
128+
>
129+
{typeof interrupt.content === "string"
130+
? interrupt.content
131+
: JSON.stringify(interrupt.content, null, 2)}
132+
</div>
133+
)}
134+
<div
135+
className="flex items-center gap-2 px-3 py-2"
136+
style={{
137+
background: "var(--bg-secondary)",
138+
borderTop: "1px solid var(--border)",
139+
}}
140+
>
141+
<input
142+
value={responseText}
143+
onChange={(e) => setResponseText(e.target.value)}
144+
onKeyDown={(e) => {
145+
if (e.key === "Enter" && !e.shiftKey && responseText.trim()) {
146+
e.preventDefault();
147+
onRespond({ response: responseText.trim() });
148+
}
149+
}}
150+
placeholder="Type your response..."
151+
className="flex-1 bg-transparent text-xs py-1 focus:outline-none placeholder:text-[var(--text-muted)]"
152+
style={{ color: "var(--text-primary)" }}
153+
/>
154+
<button
155+
onClick={() => {
156+
if (responseText.trim()) {
157+
onRespond({ response: responseText.trim() });
158+
}
159+
}}
160+
disabled={!responseText.trim()}
161+
className="text-[10px] uppercase tracking-wider font-semibold px-2 py-1 rounded transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
162+
style={{
163+
color: responseText.trim() ? "var(--accent)" : "var(--text-muted)",
164+
background: "transparent",
165+
}}
166+
>
167+
Send
168+
</button>
169+
</div>
170+
</div>
171+
);
172+
}

0 commit comments

Comments
 (0)