minitap-mobile-use 3.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minitap/mobile_use/__init__.py +0 -0
- minitap/mobile_use/agents/contextor/contextor.md +55 -0
- minitap/mobile_use/agents/contextor/contextor.py +175 -0
- minitap/mobile_use/agents/contextor/types.py +36 -0
- minitap/mobile_use/agents/cortex/cortex.md +135 -0
- minitap/mobile_use/agents/cortex/cortex.py +152 -0
- minitap/mobile_use/agents/cortex/types.py +15 -0
- minitap/mobile_use/agents/executor/executor.md +42 -0
- minitap/mobile_use/agents/executor/executor.py +87 -0
- minitap/mobile_use/agents/executor/tool_node.py +152 -0
- minitap/mobile_use/agents/hopper/hopper.md +15 -0
- minitap/mobile_use/agents/hopper/hopper.py +44 -0
- minitap/mobile_use/agents/orchestrator/human.md +12 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
- minitap/mobile_use/agents/orchestrator/types.py +11 -0
- minitap/mobile_use/agents/outputter/human.md +25 -0
- minitap/mobile_use/agents/outputter/outputter.py +85 -0
- minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
- minitap/mobile_use/agents/planner/human.md +14 -0
- minitap/mobile_use/agents/planner/planner.md +126 -0
- minitap/mobile_use/agents/planner/planner.py +101 -0
- minitap/mobile_use/agents/planner/types.py +51 -0
- minitap/mobile_use/agents/planner/utils.py +70 -0
- minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
- minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
- minitap/mobile_use/agents/video_analyzer/human.md +5 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
- minitap/mobile_use/clients/browserstack_client.py +477 -0
- minitap/mobile_use/clients/idb_client.py +429 -0
- minitap/mobile_use/clients/ios_client.py +332 -0
- minitap/mobile_use/clients/ios_client_config.py +141 -0
- minitap/mobile_use/clients/ui_automator_client.py +330 -0
- minitap/mobile_use/clients/wda_client.py +526 -0
- minitap/mobile_use/clients/wda_lifecycle.py +367 -0
- minitap/mobile_use/config.py +413 -0
- minitap/mobile_use/constants.py +3 -0
- minitap/mobile_use/context.py +106 -0
- minitap/mobile_use/controllers/__init__.py +0 -0
- minitap/mobile_use/controllers/android_controller.py +524 -0
- minitap/mobile_use/controllers/controller_factory.py +46 -0
- minitap/mobile_use/controllers/device_controller.py +182 -0
- minitap/mobile_use/controllers/ios_controller.py +436 -0
- minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
- minitap/mobile_use/controllers/types.py +106 -0
- minitap/mobile_use/controllers/unified_controller.py +193 -0
- minitap/mobile_use/graph/graph.py +160 -0
- minitap/mobile_use/graph/state.py +115 -0
- minitap/mobile_use/main.py +309 -0
- minitap/mobile_use/sdk/__init__.py +12 -0
- minitap/mobile_use/sdk/agent.py +1294 -0
- minitap/mobile_use/sdk/builders/__init__.py +10 -0
- minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
- minitap/mobile_use/sdk/builders/index.py +15 -0
- minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
- minitap/mobile_use/sdk/constants.py +1 -0
- minitap/mobile_use/sdk/examples/README.md +83 -0
- minitap/mobile_use/sdk/examples/__init__.py +1 -0
- minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
- minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
- minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
- minitap/mobile_use/sdk/services/platform.py +434 -0
- minitap/mobile_use/sdk/types/__init__.py +51 -0
- minitap/mobile_use/sdk/types/agent.py +84 -0
- minitap/mobile_use/sdk/types/exceptions.py +138 -0
- minitap/mobile_use/sdk/types/platform.py +183 -0
- minitap/mobile_use/sdk/types/task.py +269 -0
- minitap/mobile_use/sdk/utils.py +29 -0
- minitap/mobile_use/services/accessibility.py +100 -0
- minitap/mobile_use/services/llm.py +247 -0
- minitap/mobile_use/services/telemetry.py +421 -0
- minitap/mobile_use/tools/index.py +67 -0
- minitap/mobile_use/tools/mobile/back.py +52 -0
- minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
- minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
- minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
- minitap/mobile_use/tools/mobile/launch_app.py +86 -0
- minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
- minitap/mobile_use/tools/mobile/open_link.py +62 -0
- minitap/mobile_use/tools/mobile/press_key.py +83 -0
- minitap/mobile_use/tools/mobile/stop_app.py +62 -0
- minitap/mobile_use/tools/mobile/swipe.py +156 -0
- minitap/mobile_use/tools/mobile/tap.py +154 -0
- minitap/mobile_use/tools/mobile/video_recording.py +177 -0
- minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
- minitap/mobile_use/tools/scratchpad.py +147 -0
- minitap/mobile_use/tools/test_utils.py +413 -0
- minitap/mobile_use/tools/tool_wrapper.py +16 -0
- minitap/mobile_use/tools/types.py +35 -0
- minitap/mobile_use/tools/utils.py +336 -0
- minitap/mobile_use/utils/app_launch_utils.py +173 -0
- minitap/mobile_use/utils/cli_helpers.py +37 -0
- minitap/mobile_use/utils/cli_selection.py +143 -0
- minitap/mobile_use/utils/conversations.py +31 -0
- minitap/mobile_use/utils/decorators.py +124 -0
- minitap/mobile_use/utils/errors.py +6 -0
- minitap/mobile_use/utils/file.py +13 -0
- minitap/mobile_use/utils/logger.py +183 -0
- minitap/mobile_use/utils/media.py +186 -0
- minitap/mobile_use/utils/recorder.py +52 -0
- minitap/mobile_use/utils/requests_utils.py +37 -0
- minitap/mobile_use/utils/shell_utils.py +20 -0
- minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
- minitap/mobile_use/utils/time.py +6 -0
- minitap/mobile_use/utils/ui_hierarchy.py +132 -0
- minitap/mobile_use/utils/video.py +281 -0
- minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
- minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
- minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
- minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import TypeGuard
|
|
2
|
+
|
|
3
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_ai_message(message: BaseMessage) -> TypeGuard[AIMessage]:
|
|
7
|
+
return isinstance(message, AIMessage)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_human_message(message: BaseMessage) -> TypeGuard[HumanMessage]:
|
|
11
|
+
return isinstance(message, HumanMessage)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_tool_message(message: BaseMessage) -> TypeGuard[ToolMessage]:
|
|
15
|
+
return isinstance(message, ToolMessage)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_tool_for_name(tool_message: ToolMessage, name: str) -> bool:
|
|
19
|
+
return tool_message.name == name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_screenshot_message_for_llm(screenshot_base64: str):
|
|
23
|
+
prefix = "" if screenshot_base64.startswith("data:image") else "data:image/jpeg;base64,"
|
|
24
|
+
return HumanMessage(
|
|
25
|
+
content=[
|
|
26
|
+
{
|
|
27
|
+
"type": "image_url",
|
|
28
|
+
"image_url": {"url": f"{prefix}{screenshot_base64}"},
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any, TypeVar, cast, overload
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
R = TypeVar("R")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wrap_with_callbacks_sync(
|
|
10
|
+
fn: Callable[..., R],
|
|
11
|
+
*,
|
|
12
|
+
before: Callable[..., None] | None = None,
|
|
13
|
+
on_success: Callable[[R], None] | None = None,
|
|
14
|
+
on_failure: Callable[[Exception], None] | None = None,
|
|
15
|
+
suppress_exceptions: bool = False,
|
|
16
|
+
) -> Callable[..., R]:
|
|
17
|
+
@wraps(fn)
|
|
18
|
+
def wrapper(*args: Any, **kwargs: Any) -> R:
|
|
19
|
+
if before:
|
|
20
|
+
before()
|
|
21
|
+
try:
|
|
22
|
+
result = fn(*args, **kwargs)
|
|
23
|
+
if on_success:
|
|
24
|
+
on_success(result)
|
|
25
|
+
return result
|
|
26
|
+
except Exception as e:
|
|
27
|
+
if on_failure:
|
|
28
|
+
on_failure(e)
|
|
29
|
+
if suppress_exceptions:
|
|
30
|
+
return None # type: ignore
|
|
31
|
+
raise
|
|
32
|
+
|
|
33
|
+
return wrapper
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wrap_with_callbacks_async(
|
|
37
|
+
fn: Callable[..., Awaitable[R]],
|
|
38
|
+
*,
|
|
39
|
+
before: Callable[..., None] | None = None,
|
|
40
|
+
on_success: Callable[[R], None] | None = None,
|
|
41
|
+
on_failure: Callable[[Exception], None] | None = None,
|
|
42
|
+
suppress_exceptions: bool = False,
|
|
43
|
+
) -> Callable[..., Awaitable[R]]:
|
|
44
|
+
@wraps(fn)
|
|
45
|
+
async def wrapper(*args: Any, **kwargs: Any) -> R:
|
|
46
|
+
if before:
|
|
47
|
+
before()
|
|
48
|
+
try:
|
|
49
|
+
result = await fn(*args, **kwargs)
|
|
50
|
+
if on_success:
|
|
51
|
+
on_success(result)
|
|
52
|
+
return result
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if on_failure:
|
|
55
|
+
on_failure(e)
|
|
56
|
+
if suppress_exceptions:
|
|
57
|
+
return None # type: ignore
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@overload
|
|
64
|
+
def wrap_with_callbacks(
|
|
65
|
+
fn: Callable[..., Awaitable[R]],
|
|
66
|
+
*,
|
|
67
|
+
before: Callable[[], None] | None = ...,
|
|
68
|
+
on_success: Callable[[R], None] | None = ...,
|
|
69
|
+
on_failure: Callable[[Exception], None] | None = ...,
|
|
70
|
+
suppress_exceptions: bool = ...,
|
|
71
|
+
) -> Callable[..., Awaitable[R]]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def wrap_with_callbacks(
|
|
76
|
+
*,
|
|
77
|
+
before: Callable[..., None] | None = ...,
|
|
78
|
+
on_success: Callable[[Any], None] | None = ...,
|
|
79
|
+
on_failure: Callable[[Exception], None] | None = ...,
|
|
80
|
+
suppress_exceptions: bool = ...,
|
|
81
|
+
) -> Callable[[Callable[..., R]], Callable[..., R]]: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@overload
|
|
85
|
+
def wrap_with_callbacks(
|
|
86
|
+
fn: Callable[..., R],
|
|
87
|
+
*,
|
|
88
|
+
before: Callable[[], None] | None = ...,
|
|
89
|
+
on_success: Callable[[R], None] | None = ...,
|
|
90
|
+
on_failure: Callable[[Exception], None] | None = ...,
|
|
91
|
+
suppress_exceptions: bool = ...,
|
|
92
|
+
) -> Callable[..., R]: ...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def wrap_with_callbacks(
|
|
96
|
+
fn: Callable[..., Any] | None = None,
|
|
97
|
+
*,
|
|
98
|
+
before: Callable[[], None] | None = None,
|
|
99
|
+
on_success: Callable[[Any], None] | None = None,
|
|
100
|
+
on_failure: Callable[[Exception], None] | None = None,
|
|
101
|
+
suppress_exceptions: bool = False,
|
|
102
|
+
) -> Any:
|
|
103
|
+
def decorator(func: Callable[..., Any]) -> Any:
|
|
104
|
+
if asyncio.iscoroutinefunction(func):
|
|
105
|
+
return wrap_with_callbacks_async(
|
|
106
|
+
cast(Callable[..., Awaitable[Any]], func),
|
|
107
|
+
before=before,
|
|
108
|
+
on_success=on_success,
|
|
109
|
+
on_failure=on_failure,
|
|
110
|
+
suppress_exceptions=suppress_exceptions,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
return wrap_with_callbacks_sync(
|
|
114
|
+
cast(Callable[..., Any], func),
|
|
115
|
+
before=before,
|
|
116
|
+
on_success=on_success,
|
|
117
|
+
on_failure=on_failure,
|
|
118
|
+
suppress_exceptions=suppress_exceptions,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if fn is None:
|
|
122
|
+
return decorator
|
|
123
|
+
else:
|
|
124
|
+
return decorator(fn)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import IO
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def strip_json_comments(text: str) -> str:
|
|
7
|
+
text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
|
|
8
|
+
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
|
|
9
|
+
return text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_jsonc(file: IO) -> dict:
|
|
13
|
+
return json.loads(strip_json_comments(file.read()))
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from colorama import Fore, Style, init
|
|
7
|
+
|
|
8
|
+
init(autoreset=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LogLevel(Enum):
|
|
12
|
+
DEBUG = ("DEBUG", Fore.MAGENTA, "🔍")
|
|
13
|
+
INFO = ("INFO", Fore.WHITE, "ℹ")
|
|
14
|
+
SUCCESS = ("SUCCESS", Fore.GREEN, "✓")
|
|
15
|
+
WARNING = ("WARNING", Fore.YELLOW, "⚠")
|
|
16
|
+
ERROR = ("ERROR", Fore.RED, "❌")
|
|
17
|
+
CRITICAL = ("CRITICAL", Fore.RED + Style.BRIGHT, "💥")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MobileUseLogger:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
log_file: str | Path | None = None,
|
|
25
|
+
console_level: str = "INFO",
|
|
26
|
+
file_level: str = "DEBUG",
|
|
27
|
+
enable_file_logging: bool = True,
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize the MobileUse logger.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: Logger name (usually __name__)
|
|
34
|
+
log_file: Path to log file (defaults to logs/{name}.log)
|
|
35
|
+
console_level: Minimum level for console output
|
|
36
|
+
file_level: Minimum level for file output
|
|
37
|
+
enable_file_logging: Whether to enable file logging
|
|
38
|
+
"""
|
|
39
|
+
self.name = name
|
|
40
|
+
self.logger = logging.getLogger(name)
|
|
41
|
+
self.logger.setLevel(logging.DEBUG)
|
|
42
|
+
|
|
43
|
+
self.logger.handlers.clear()
|
|
44
|
+
|
|
45
|
+
self._setup_console_handler(console_level)
|
|
46
|
+
|
|
47
|
+
if enable_file_logging:
|
|
48
|
+
self._setup_file_handler(log_file, file_level)
|
|
49
|
+
|
|
50
|
+
def _setup_console_handler(self, level: str):
|
|
51
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
52
|
+
console_handler.setLevel(getattr(logging, level.upper()))
|
|
53
|
+
|
|
54
|
+
console_formatter = ColoredFormatter()
|
|
55
|
+
console_handler.setFormatter(console_formatter)
|
|
56
|
+
|
|
57
|
+
self.logger.addHandler(console_handler)
|
|
58
|
+
|
|
59
|
+
def _setup_file_handler(self, log_file: str | Path | None, level: str):
|
|
60
|
+
if log_file is None:
|
|
61
|
+
log_file = Path("logs") / f"{self.name.replace('.', '_')}.log"
|
|
62
|
+
|
|
63
|
+
log_file = Path(log_file)
|
|
64
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
67
|
+
file_handler.setLevel(getattr(logging, level.upper()))
|
|
68
|
+
|
|
69
|
+
file_formatter = logging.Formatter(
|
|
70
|
+
fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
|
71
|
+
)
|
|
72
|
+
file_handler.setFormatter(file_formatter)
|
|
73
|
+
|
|
74
|
+
self.logger.addHandler(file_handler)
|
|
75
|
+
|
|
76
|
+
def debug(self, message: str, **kwargs):
|
|
77
|
+
self.logger.debug(message, extra={"log_level": LogLevel.DEBUG}, **kwargs)
|
|
78
|
+
|
|
79
|
+
def info(self, message: str, **kwargs):
|
|
80
|
+
self.logger.info(message, extra={"log_level": LogLevel.INFO}, **kwargs)
|
|
81
|
+
|
|
82
|
+
def success(self, message: str, **kwargs):
|
|
83
|
+
self.logger.info(message, extra={"log_level": LogLevel.SUCCESS}, **kwargs)
|
|
84
|
+
|
|
85
|
+
def warning(self, message: str, **kwargs):
|
|
86
|
+
self.logger.warning(message, extra={"log_level": LogLevel.WARNING}, **kwargs)
|
|
87
|
+
|
|
88
|
+
def error(self, message: str, **kwargs):
|
|
89
|
+
self.logger.error(message, extra={"log_level": LogLevel.ERROR}, **kwargs)
|
|
90
|
+
|
|
91
|
+
def critical(self, message: str, **kwargs):
|
|
92
|
+
self.logger.critical(message, extra={"log_level": LogLevel.CRITICAL}, **kwargs)
|
|
93
|
+
|
|
94
|
+
def header(self, message: str, **_kwargs):
|
|
95
|
+
separator = "=" * 60
|
|
96
|
+
colored_separator = f"{Fore.CYAN}{separator}{Style.RESET_ALL}"
|
|
97
|
+
colored_message = f"{Fore.CYAN}{message}{Style.RESET_ALL}"
|
|
98
|
+
|
|
99
|
+
print(colored_separator)
|
|
100
|
+
print(colored_message)
|
|
101
|
+
print(colored_separator)
|
|
102
|
+
self.logger.info(f"\n{separator}\n{message}\n{separator}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ColoredFormatter(logging.Formatter):
|
|
106
|
+
def format(self, record):
|
|
107
|
+
log_level = getattr(record, "log_level", LogLevel.INFO)
|
|
108
|
+
_level_name, color, symbol = log_level.value
|
|
109
|
+
|
|
110
|
+
colored_message = f"{color}{symbol} {record.getMessage()}{Style.RESET_ALL}"
|
|
111
|
+
|
|
112
|
+
return colored_message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_loggers = {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_logger(
|
|
119
|
+
name: str,
|
|
120
|
+
log_file: str | Path | None = None,
|
|
121
|
+
console_level: str = "INFO",
|
|
122
|
+
file_level: str = "DEBUG",
|
|
123
|
+
enable_file_logging: bool = False,
|
|
124
|
+
) -> MobileUseLogger:
|
|
125
|
+
"""
|
|
126
|
+
Get or create a logger instance.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
name: Logger name (usually __name__)
|
|
130
|
+
log_file: Path to log file (defaults to logs/{name}.log)
|
|
131
|
+
console_level: Minimum level for console output
|
|
132
|
+
file_level: Minimum level for file output
|
|
133
|
+
enable_file_logging: Whether to enable file logging
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
MobileUseLogger instance
|
|
137
|
+
"""
|
|
138
|
+
if name not in _loggers:
|
|
139
|
+
_loggers[name] = MobileUseLogger(
|
|
140
|
+
name=name,
|
|
141
|
+
log_file=log_file,
|
|
142
|
+
console_level=console_level,
|
|
143
|
+
file_level=file_level,
|
|
144
|
+
enable_file_logging=enable_file_logging,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return _loggers[name]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def log_debug(message: str, logger_name: str = "mobile-use"):
|
|
151
|
+
get_logger(logger_name).debug(message)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def log_info(message: str, logger_name: str = "mobile-use"):
|
|
155
|
+
get_logger(logger_name).info(message)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def log_success(message: str, logger_name: str = "mobile-use"):
|
|
159
|
+
get_logger(logger_name).success(message)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def log_warning(message: str, logger_name: str = "mobile-use"):
|
|
163
|
+
get_logger(logger_name).warning(message)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def log_error(message: str, logger_name: str = "mobile-use"):
|
|
167
|
+
get_logger(logger_name).error(message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def log_critical(message: str, logger_name: str = "mobile-use"):
|
|
171
|
+
get_logger(logger_name).critical(message)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def log_header(message: str, logger_name: str = "mobile-use"):
|
|
175
|
+
get_logger(logger_name).header(message)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_server_logger() -> MobileUseLogger:
|
|
179
|
+
return get_logger(
|
|
180
|
+
name="mobile-use.servers",
|
|
181
|
+
console_level="INFO",
|
|
182
|
+
file_level="DEBUG",
|
|
183
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
USE_FFMPEG_GIF = os.environ.get("USE_FFMPEG_GIF", "").lower() in ("1", "true", "yes")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def quantize_and_save_gif_from_paths(
|
|
13
|
+
image_paths: list[Path],
|
|
14
|
+
output_path: Path,
|
|
15
|
+
colors: int = 128,
|
|
16
|
+
duration: int = 100,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Create an optimized GIF from image file paths.
|
|
20
|
+
|
|
21
|
+
By default uses PIL (loads all frames into memory).
|
|
22
|
+
Set USE_FFMPEG_GIF=1 env var to use ffmpeg for memory-efficient streaming.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
image_paths: List of paths to image files (must be sorted in desired order)
|
|
26
|
+
output_path: Path where the GIF will be saved
|
|
27
|
+
colors: Number of colors to use in quantization (lower = smaller file)
|
|
28
|
+
duration: Duration of each frame in milliseconds
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If image_paths list is empty
|
|
32
|
+
RuntimeError: If ffmpeg fails (when USE_FFMPEG_GIF is enabled)
|
|
33
|
+
"""
|
|
34
|
+
if not image_paths:
|
|
35
|
+
raise ValueError("image_paths must not be empty")
|
|
36
|
+
|
|
37
|
+
if USE_FFMPEG_GIF:
|
|
38
|
+
_save_gif_ffmpeg(image_paths, output_path, colors, duration)
|
|
39
|
+
else:
|
|
40
|
+
_save_gif_pillow(image_paths, output_path, colors, duration)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_gif_pillow(
|
|
44
|
+
image_paths: list[Path],
|
|
45
|
+
output_path: Path,
|
|
46
|
+
colors: int,
|
|
47
|
+
duration: int,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Create GIF using PIL (loads all frames into memory)."""
|
|
50
|
+
|
|
51
|
+
def frame_generator():
|
|
52
|
+
for path in image_paths:
|
|
53
|
+
with Image.open(path) as img:
|
|
54
|
+
if img.mode != "RGB":
|
|
55
|
+
img = img.convert("RGB")
|
|
56
|
+
yield img.quantize(colors=colors, method=2)
|
|
57
|
+
|
|
58
|
+
frames = frame_generator()
|
|
59
|
+
first_frame = next(frames)
|
|
60
|
+
first_frame.save(
|
|
61
|
+
output_path,
|
|
62
|
+
save_all=True,
|
|
63
|
+
append_images=frames,
|
|
64
|
+
loop=0,
|
|
65
|
+
optimize=True,
|
|
66
|
+
duration=duration,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _save_gif_ffmpeg(
|
|
71
|
+
image_paths: list[Path],
|
|
72
|
+
output_path: Path,
|
|
73
|
+
colors: int,
|
|
74
|
+
duration: int,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Create GIF using ffmpeg (memory-efficient streaming)."""
|
|
77
|
+
fps = 1000 / duration
|
|
78
|
+
|
|
79
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
80
|
+
for i, path in enumerate(image_paths):
|
|
81
|
+
f.write(f"file '{path.absolute()}'\n")
|
|
82
|
+
# Last file needs no duration (ffmpeg concat demuxer uses it as the final frame)
|
|
83
|
+
if i < len(image_paths) - 1:
|
|
84
|
+
f.write(f"duration {duration / 1000}\n")
|
|
85
|
+
# Repeat last file to ensure it's included (ffmpeg concat demuxer quirk)
|
|
86
|
+
f.write(f"file '{image_paths[-1].absolute()}'\n")
|
|
87
|
+
concat_file = Path(f.name)
|
|
88
|
+
|
|
89
|
+
palette_path = output_path.with_suffix(".palette.png")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
[
|
|
94
|
+
"ffmpeg",
|
|
95
|
+
"-y",
|
|
96
|
+
"-f",
|
|
97
|
+
"concat",
|
|
98
|
+
"-safe",
|
|
99
|
+
"0",
|
|
100
|
+
"-i",
|
|
101
|
+
str(concat_file),
|
|
102
|
+
"-vf",
|
|
103
|
+
f"palettegen=max_colors={min(colors, 256)}:stats_mode=diff",
|
|
104
|
+
str(palette_path),
|
|
105
|
+
],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
)
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
raise RuntimeError(f"ffmpeg palette generation failed: {result.stderr}")
|
|
111
|
+
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
[
|
|
114
|
+
"ffmpeg",
|
|
115
|
+
"-y",
|
|
116
|
+
"-f",
|
|
117
|
+
"concat",
|
|
118
|
+
"-safe",
|
|
119
|
+
"0",
|
|
120
|
+
"-i",
|
|
121
|
+
str(concat_file),
|
|
122
|
+
"-i",
|
|
123
|
+
str(palette_path),
|
|
124
|
+
"-lavfi",
|
|
125
|
+
f"fps={fps},paletteuse=dither=bayer:bayer_scale=5",
|
|
126
|
+
"-loop",
|
|
127
|
+
"0",
|
|
128
|
+
str(output_path),
|
|
129
|
+
],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
)
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
raise RuntimeError(f"ffmpeg GIF creation failed: {result.stderr}")
|
|
135
|
+
|
|
136
|
+
finally:
|
|
137
|
+
concat_file.unlink(missing_ok=True)
|
|
138
|
+
if palette_path.exists():
|
|
139
|
+
palette_path.unlink()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_gif_from_trace_folder(trace_folder_path: Path):
|
|
143
|
+
image_files: list[Path] = []
|
|
144
|
+
|
|
145
|
+
for file in trace_folder_path.iterdir():
|
|
146
|
+
if file.suffix == ".jpeg":
|
|
147
|
+
image_files.append(file)
|
|
148
|
+
|
|
149
|
+
image_files.sort(key=lambda f: int(f.stem))
|
|
150
|
+
|
|
151
|
+
print(f"Found {len(image_files)} images to compile")
|
|
152
|
+
|
|
153
|
+
if not image_files:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
gif_path = trace_folder_path / "trace.gif"
|
|
157
|
+
quantize_and_save_gif_from_paths(image_files, gif_path)
|
|
158
|
+
print(f"GIF created at {gif_path}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def remove_images_from_trace_folder(trace_folder_path: Path):
|
|
162
|
+
for file in trace_folder_path.iterdir():
|
|
163
|
+
if file.suffix == ".jpeg":
|
|
164
|
+
file.unlink()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
|
168
|
+
steps = []
|
|
169
|
+
for file in trace_folder_path.iterdir():
|
|
170
|
+
if file.suffix == ".json":
|
|
171
|
+
with open(file, encoding="utf-8", errors="ignore") as f:
|
|
172
|
+
json_content = f.read()
|
|
173
|
+
steps.append({"timestamp": int(file.stem), "data": json_content})
|
|
174
|
+
|
|
175
|
+
steps.sort(key=lambda f: f["timestamp"])
|
|
176
|
+
|
|
177
|
+
print("Found " + str(len(steps)) + " steps to compile")
|
|
178
|
+
|
|
179
|
+
with open(trace_folder_path / "steps.json", "w", encoding="utf-8", errors="ignore") as f:
|
|
180
|
+
f.write(json.dumps(steps))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
|
|
184
|
+
for file in trace_folder_path.iterdir():
|
|
185
|
+
if file.suffix == ".json" and file.name != "steps.json":
|
|
186
|
+
file.unlink()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from colorama import Fore, Style
|
|
5
|
+
from langchain_core.messages import BaseMessage
|
|
6
|
+
|
|
7
|
+
from minitap.mobile_use.context import MobileUseContext
|
|
8
|
+
from minitap.mobile_use.controllers.controller_factory import create_device_controller
|
|
9
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def record_interaction(ctx: MobileUseContext, response: BaseMessage):
|
|
15
|
+
if not ctx.execution_setup:
|
|
16
|
+
raise ValueError("No execution setup found")
|
|
17
|
+
if not ctx.execution_setup.traces_path or not ctx.execution_setup.trace_name:
|
|
18
|
+
raise ValueError("No traces path or trace name found")
|
|
19
|
+
|
|
20
|
+
logger.info("Recording interaction")
|
|
21
|
+
controller = create_device_controller(ctx)
|
|
22
|
+
screenshot_base64 = await controller.screenshot()
|
|
23
|
+
logger.info("Screenshot taken")
|
|
24
|
+
try:
|
|
25
|
+
controller = create_device_controller(ctx)
|
|
26
|
+
compressed_screenshot_base64 = controller.get_compressed_b64_screenshot(screenshot_base64)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
logger.error(f"Error compressing screenshot: {e}")
|
|
29
|
+
return "Could not record this interaction"
|
|
30
|
+
timestamp = time.time()
|
|
31
|
+
folder = ctx.execution_setup.traces_path.joinpath(ctx.execution_setup.trace_name).resolve()
|
|
32
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
try:
|
|
34
|
+
with open(
|
|
35
|
+
folder.joinpath(f"{int(timestamp)}.jpeg").resolve(),
|
|
36
|
+
"wb",
|
|
37
|
+
) as f:
|
|
38
|
+
f.write(base64.b64decode(compressed_screenshot_base64))
|
|
39
|
+
|
|
40
|
+
with open(
|
|
41
|
+
folder.joinpath(f"{int(timestamp)}.json").resolve(),
|
|
42
|
+
"w",
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
) as f:
|
|
45
|
+
f.write(response.model_dump_json())
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Error recording interaction: {e}")
|
|
48
|
+
return "Screenshot recorded successfully"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def log_agent_thought(agent_thought: str):
|
|
52
|
+
logger.info(f"💭 {Fore.LIGHTMAGENTA_EX}{agent_thought}{Style.RESET_ALL}")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
3
|
+
|
|
4
|
+
logger = get_logger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def curl_from_request(req: requests.PreparedRequest) -> str:
|
|
8
|
+
"""Converts a requests.PreparedRequest object to a valid cURL command string."""
|
|
9
|
+
command = ["curl", f"-X {req.method}"]
|
|
10
|
+
|
|
11
|
+
for key, value in req.headers.items():
|
|
12
|
+
command.append(f'-H "{key}: {value}"')
|
|
13
|
+
|
|
14
|
+
if req.body:
|
|
15
|
+
body = req.body
|
|
16
|
+
if isinstance(body, bytes):
|
|
17
|
+
body = body.decode("utf-8")
|
|
18
|
+
# Escape single quotes in the body for shell safety
|
|
19
|
+
body = body.replace("'", "'\\''")
|
|
20
|
+
command.append(f"-d '{body}'")
|
|
21
|
+
|
|
22
|
+
command.append(f"'{req.url}'")
|
|
23
|
+
|
|
24
|
+
return " ".join(command)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def logging_hook(response, *args, **kwargs):
|
|
28
|
+
"""Hook to log the request as a cURL command."""
|
|
29
|
+
curl_command = curl_from_request(response.request)
|
|
30
|
+
logger.debug(f"\n--- cURL Command ---\n{curl_command}\n--------------------")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_session_with_curl_logging() -> requests.Session:
|
|
34
|
+
"""Returns a requests.Session with cURL logging enabled."""
|
|
35
|
+
session = requests.Session()
|
|
36
|
+
session.hooks["response"] = [logging_hook]
|
|
37
|
+
return session
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_shell_command_on_host(command: str) -> str:
|
|
5
|
+
"""Helper to run a shell command on the host and return the output."""
|
|
6
|
+
try:
|
|
7
|
+
result = subprocess.run(
|
|
8
|
+
command,
|
|
9
|
+
shell=True,
|
|
10
|
+
check=True,
|
|
11
|
+
capture_output=True,
|
|
12
|
+
text=True,
|
|
13
|
+
)
|
|
14
|
+
return result.stdout.strip()
|
|
15
|
+
except subprocess.CalledProcessError as e:
|
|
16
|
+
# Log the error and stderr for better debugging
|
|
17
|
+
error_message = (
|
|
18
|
+
f"Command '{command}' failed with exit code {e.returncode}.\nStderr: {e.stderr.strip()}"
|
|
19
|
+
)
|
|
20
|
+
raise RuntimeError(error_message) from e
|