mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-07 08:25:54 +00:00
393 lines
15 KiB
Python
393 lines
15 KiB
Python
import asyncio
|
|
import os
|
|
import re
|
|
from typing import Any, Dict, Optional
|
|
from openspace.grounding.core.transport.connectors import AioHttpConnector
|
|
from .actions import build_pyautogui_command, KEYBOARD_KEYS
|
|
from openspace.utils.logging import Logger
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
|
|
class GUIConnector(AioHttpConnector):
|
|
"""
|
|
Connector for desktop_env HTTP API.
|
|
Provides action execution and observation methods.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
vm_ip: str,
|
|
server_port: int = 5000,
|
|
timeout: int = 90,
|
|
retry_times: int = 3,
|
|
retry_interval: float = 5.0,
|
|
pkgs_prefix: str = "import pyautogui; import time; pyautogui.FAILSAFE = False; {command}",
|
|
auth_token: str | None = None,
|
|
):
|
|
"""
|
|
Initialize GUI connector.
|
|
|
|
Args:
|
|
vm_ip: IP address of the VM running desktop_env
|
|
server_port: Port of the desktop_env HTTP server
|
|
timeout: Request timeout in seconds
|
|
retry_times: Number of retries for failed requests
|
|
retry_interval: Interval between retries in seconds
|
|
pkgs_prefix: Python command prefix for pyautogui setup
|
|
"""
|
|
base_url = f"http://{vm_ip}:{server_port}"
|
|
token = auth_token or os.environ.get("OPENSPACE_LOCAL_SERVER_TOKEN", "").strip()
|
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
|
super().__init__(base_url, timeout=timeout, headers=headers)
|
|
|
|
self.vm_ip = vm_ip
|
|
self.server_port = server_port
|
|
self.retry_times = retry_times
|
|
self.retry_interval = retry_interval
|
|
self.pkgs_prefix = pkgs_prefix
|
|
self.timeout = timeout
|
|
|
|
async def _retry_invoke(
|
|
self,
|
|
operation_name: str,
|
|
operation_func,
|
|
*args,
|
|
**kwargs
|
|
):
|
|
"""
|
|
Execute operation with retry logic.
|
|
|
|
Args:
|
|
operation_name: Name of operation for logging
|
|
operation_func: Async function to execute
|
|
*args: Positional arguments for operation_func
|
|
**kwargs: Keyword arguments for operation_func
|
|
|
|
Returns:
|
|
Operation result
|
|
|
|
Raises:
|
|
Exception: Last exception after all retries fail
|
|
"""
|
|
last_exc: Exception | None = None
|
|
|
|
for attempt in range(1, self.retry_times + 1):
|
|
try:
|
|
result = await operation_func(*args, **kwargs)
|
|
logger.debug("%s executed successfully (attempt %d/%d)", operation_name, attempt, self.retry_times)
|
|
return result
|
|
except asyncio.TimeoutError as exc:
|
|
logger.error("%s timed out", operation_name)
|
|
raise RuntimeError(f"{operation_name} timed out after {self.timeout} seconds") from exc
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if attempt == self.retry_times:
|
|
break
|
|
logger.warning(
|
|
"%s failed (attempt %d/%d): %s, retrying in %.1f seconds...",
|
|
operation_name, attempt, self.retry_times, exc, self.retry_interval
|
|
)
|
|
await asyncio.sleep(self.retry_interval)
|
|
|
|
error_msg = f"{operation_name} failed after {self.retry_times} retries"
|
|
logger.error(error_msg)
|
|
raise last_exc or RuntimeError(error_msg)
|
|
|
|
@staticmethod
|
|
def _is_valid_image_response(content_type: str, data: Optional[bytes]) -> bool:
|
|
"""Validate image response using magic bytes."""
|
|
if not isinstance(data, (bytes, bytearray)) or not data:
|
|
return False
|
|
# PNG magic
|
|
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
return True
|
|
# JPEG magic
|
|
if len(data) >= 3 and data[:3] == b"\xff\xd8\xff":
|
|
return True
|
|
# Fallback to content-type
|
|
if content_type and ("image/png" in content_type or "image/jpeg" in content_type):
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _fix_pyautogui_less_than_bug(command: str) -> str:
|
|
"""
|
|
Fix PyAutoGUI '<' character bug by converting it to hotkey("shift", ',') calls.
|
|
|
|
This fixes the known PyAutoGUI issue where typing '<' produces '>' instead.
|
|
References:
|
|
- https://github.com/asweigart/pyautogui/issues/198
|
|
- https://github.com/xlang-ai/OSWorld/issues/257
|
|
|
|
Args:
|
|
command (str): The original pyautogui command
|
|
|
|
Returns:
|
|
str: The fixed command with '<' characters handled properly
|
|
"""
|
|
# Pattern to match press('<') or press('\u003c') calls
|
|
press_pattern = r'pyautogui\.press\(["\'](?:<|\\u003c)["\']\)'
|
|
|
|
# Handle press('<') calls
|
|
def replace_press_less_than(match):
|
|
return 'pyautogui.hotkey("shift", ",")'
|
|
|
|
# First handle press('<') calls
|
|
command = re.sub(press_pattern, replace_press_less_than, command)
|
|
|
|
# Pattern to match typewrite calls with quoted strings
|
|
typewrite_pattern = r'pyautogui\.typewrite\((["\'])(.*?)\1\)'
|
|
|
|
# Then handle typewrite calls
|
|
def process_typewrite_match(match):
|
|
quote_char = match.group(1)
|
|
content = match.group(2)
|
|
|
|
# Preprocess: Try to decode Unicode escapes like \u003c to actual '<'
|
|
# This handles cases where '<' is represented as escaped Unicode
|
|
try:
|
|
# Attempt to decode unicode escapes
|
|
decoded_content = content.encode('utf-8').decode('unicode_escape')
|
|
content = decoded_content
|
|
except UnicodeDecodeError:
|
|
# If decoding fails, proceed with original content to avoid breaking existing logic
|
|
pass # Graceful degradation - fall back to original content if decoding fails
|
|
|
|
# Check if content contains '<'
|
|
if '<' not in content:
|
|
return match.group(0)
|
|
|
|
# Split by '<' and rebuild
|
|
parts = content.split('<')
|
|
result_parts = []
|
|
|
|
for i, part in enumerate(parts):
|
|
if i == 0:
|
|
# First part
|
|
if part:
|
|
result_parts.append(f"pyautogui.typewrite({quote_char}{part}{quote_char})")
|
|
else:
|
|
# Add hotkey for '<' and then typewrite for the rest
|
|
result_parts.append('pyautogui.hotkey("shift", ",")')
|
|
if part:
|
|
result_parts.append(f"pyautogui.typewrite({quote_char}{part}{quote_char})")
|
|
|
|
return '; '.join(result_parts)
|
|
|
|
command = re.sub(typewrite_pattern, process_typewrite_match, command)
|
|
|
|
return command
|
|
|
|
async def get_screen_size(self) -> Optional[tuple[int, int]]:
|
|
"""
|
|
Get actual screen size from desktop environment using pyautogui.
|
|
|
|
Returns:
|
|
(width, height) tuple, or None on failure
|
|
"""
|
|
try:
|
|
command = "print(pyautogui.size())"
|
|
result = await self.execute_python_command(command)
|
|
if result and result.get("status") == "success":
|
|
output = result.get("output", "")
|
|
# Parse output like "Size(width=2880, height=1800)"
|
|
import re
|
|
match = re.search(r'width=(\d+).*height=(\d+)', output)
|
|
if match:
|
|
width = int(match.group(1))
|
|
height = int(match.group(2))
|
|
logger.info(f"Detected screen size: {width}x{height}")
|
|
return (width, height)
|
|
logger.warning(f"Failed to detect screen size, output: {result}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Failed to get screen size: {e}")
|
|
return None
|
|
|
|
async def get_screenshot(self) -> Optional[bytes]:
|
|
"""
|
|
Get screenshot from desktop environment.
|
|
|
|
Returns:
|
|
Screenshot image bytes (PNG/JPEG), or None on failure
|
|
"""
|
|
try:
|
|
async def _get():
|
|
response = await self._request("GET", "/screenshot", timeout=10)
|
|
if response.status == 200:
|
|
content_type = response.headers.get("Content-Type", "")
|
|
content = await response.read()
|
|
if self._is_valid_image_response(content_type, content):
|
|
return content
|
|
else:
|
|
raise ValueError("Invalid screenshot format")
|
|
else:
|
|
raise RuntimeError(f"HTTP {response.status}")
|
|
|
|
return await self._retry_invoke("get_screenshot", _get)
|
|
except Exception as e:
|
|
logger.error(f"Failed to get screenshot: {e}")
|
|
return None
|
|
|
|
async def execute_python_command(self, command: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Execute a Python command on desktop environment.
|
|
Used for pyautogui commands.
|
|
|
|
Args:
|
|
command: Python command to execute
|
|
|
|
Returns:
|
|
Response dict with execution result, or None on failure
|
|
"""
|
|
try:
|
|
# Apply '<' character fix for PyAutoGUI bug
|
|
fixed_command = self._fix_pyautogui_less_than_bug(command)
|
|
|
|
command_list = ["python", "-c", self.pkgs_prefix.format(command=fixed_command)]
|
|
payload = {"command": command_list, "shell": False}
|
|
|
|
async def _execute():
|
|
return await self.post_json("/execute", payload)
|
|
|
|
return await self._retry_invoke("execute_python_command", _execute)
|
|
except Exception as e:
|
|
logger.error(f"Failed to execute command: {e}")
|
|
return None
|
|
|
|
async def execute_action(self, action_type: str, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
"""
|
|
Execute a desktop action.
|
|
This is the main method for action space execution.
|
|
|
|
Args:
|
|
action_type: Action type (e.g., 'CLICK', 'TYPING')
|
|
parameters: Action parameters
|
|
|
|
Returns:
|
|
Result dict with execution status
|
|
"""
|
|
parameters = parameters or {}
|
|
|
|
# Handle control actions
|
|
if action_type in ['WAIT', 'FAIL', 'DONE']:
|
|
return {
|
|
"status": "success",
|
|
"action_type": action_type,
|
|
"message": f"Control action {action_type} acknowledged"
|
|
}
|
|
|
|
# Validate keyboard keys
|
|
if action_type in ['PRESS', 'KEY_DOWN', 'KEY_UP']:
|
|
key = parameters.get('key')
|
|
if key and key not in KEYBOARD_KEYS:
|
|
return {
|
|
"status": "error",
|
|
"action_type": action_type,
|
|
"error": f"Invalid key: {key}. Must be in supported keyboard keys."
|
|
}
|
|
|
|
if action_type == 'HOTKEY':
|
|
keys = parameters.get('keys', [])
|
|
invalid_keys = [k for k in keys if k not in KEYBOARD_KEYS]
|
|
if invalid_keys:
|
|
return {
|
|
"status": "error",
|
|
"action_type": action_type,
|
|
"error": f"Invalid keys: {invalid_keys}"
|
|
}
|
|
|
|
# Build pyautogui command
|
|
command = build_pyautogui_command(action_type, parameters)
|
|
|
|
if command is None:
|
|
return {
|
|
"status": "error",
|
|
"action_type": action_type,
|
|
"error": f"Unsupported action type: {action_type}"
|
|
}
|
|
|
|
# Execute command
|
|
result = await self.execute_python_command(command)
|
|
|
|
if result:
|
|
return {
|
|
"status": "success",
|
|
"action_type": action_type,
|
|
"parameters": parameters,
|
|
"result": result
|
|
}
|
|
else:
|
|
return {
|
|
"status": "error",
|
|
"action_type": action_type,
|
|
"parameters": parameters,
|
|
"error": "Command execution failed"
|
|
}
|
|
|
|
async def get_accessibility_tree(self, max_depth: int = 5) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get accessibility tree from desktop environment.
|
|
|
|
Args:
|
|
max_depth: Maximum depth of accessibility tree traversal
|
|
|
|
Returns:
|
|
Accessibility tree as dict, or None on failure
|
|
"""
|
|
try:
|
|
async def _get():
|
|
response = await self._request("GET", "/accessibility", timeout=10)
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
return data.get("AT")
|
|
else:
|
|
raise RuntimeError(f"HTTP {response.status}")
|
|
|
|
return await self._retry_invoke("get_accessibility_tree", _get)
|
|
except Exception as e:
|
|
logger.error(f"Failed to get accessibility tree: {e}")
|
|
return None
|
|
|
|
async def get_cursor_position(self) -> Optional[tuple[int, int]]:
|
|
"""
|
|
Get current mouse cursor position.
|
|
Useful for GUI debugging and relative positioning.
|
|
|
|
Returns:
|
|
(x, y) tuple, or None on failure
|
|
"""
|
|
try:
|
|
async def _get():
|
|
result = await self.get_json("/cursor_position")
|
|
return (result.get("x"), result.get("y"))
|
|
|
|
return await self._retry_invoke("get_cursor_position", _get)
|
|
except Exception as e:
|
|
logger.error(f"Failed to get cursor position: {e}")
|
|
return None
|
|
|
|
async def invoke(self, name: str, params: dict[str, Any]) -> Any:
|
|
"""
|
|
Unified RPC entry for operations.
|
|
Required by BaseConnector.
|
|
|
|
Args:
|
|
name: Operation name (action_type or observation method)
|
|
params: Operation parameters
|
|
|
|
Returns:
|
|
Operation result
|
|
"""
|
|
# Handle observation methods
|
|
if name == "screenshot":
|
|
return await self.get_screenshot()
|
|
elif name == "accessibility_tree":
|
|
max_depth = params.get("max_depth", 5) if params else 5
|
|
return await self.get_accessibility_tree(max_depth)
|
|
elif name == "cursor_position":
|
|
return await self.get_cursor_position()
|
|
else:
|
|
# Treat as action
|
|
return await self.execute_action(name.upper(), params or {})
|