mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-15 23:31:05 +00:00
* update * refactor(memory): remove unnecessary type check and update error logging * refactor(core): standardize logger import and update agentscope dependency * fix(memory): disable console output and add logging for summarizer component * feat(core): replace OpenAI token counter with custom ReMe token counter - Replace OpenAITokenCounter with ReMeTokenCounter implementation - Add support for HuggingFace mirror and configurable tokenizer - Register ReMeTokenCounter as default token counter in registry - Update config to use hf backend with Qwen2.5-7B-Instruct model refactor(memory): convert token counting methods to async in message handlers - Change count_str_token, stat_message, count_msgs_token to async methods - Update format_msgs_to_str and context_check to use async token counting - Modify _format_tool_result_output to support async token counting - Adjust all dependent methods to await async token counting calls feat(memory): add dialog persistence to in-memory storage - Implement _append_messages_to_dialog for saving messages to JSONL files - Add dialog_path parameter to ReMeInMemoryMemory constructor - Persist messages to daily JSONL files based on timestamp grouping - Update mark_messages_compressed to save and remove compressed messages - Modify clear_content to persist all messages before clearing memory refactor(ops): update token counter type hints and initialization - Change BaseOp to use HuggingFaceTokenCounter instead of TokenCounterBase - Update type annotations for as_token_counter property and parameters - Remove direct token counter injection from Compactor and ContextChecker - Pass as_token_counter parameter through service context mechanism style(logging): improve error logging with exception details - Replace logger.error with logger.exception in browser control tool - Change logger.error to logger.exception in memory get tool error handling - Add proper exception logging with stack trace information chore(config): add token counter configuration to light YAML - Add as_token_counters section with default hf backend configuration - Configure Qwen/Qwen2.5-7B-Instruct model with mirror support enabled - Set up pretrained_model_name_or_path and use_mirror parameters test(context): update context check tests to async implementation - Convert verify_context_check_invariants to async function - Update context check test methods to use async calls - Change stat_message calls to await async implementation - Modify test_empty_messages and test_below_threshold_returns_all to async * feat(core): implement context checking and memory management features * refactor(core): replace direct loguru import with logger utility function * refactor(reme): remove RuntimeContext dependency and simplify context checking * feat(docs): add raw conversation persistence to ReMe framework
2624 lines
85 KiB
Python
2624 lines
85 KiB
Python
# -*- coding: utf-8 -*-
|
|
# flake8: noqa: E501
|
|
# pylint: disable=too-many-lines
|
|
"""Browser automation tool using Playwright.
|
|
|
|
Single tool with action-based API matching browser MCP: start, stop, open,
|
|
navigate, navigate_back, screenshot, snapshot, click, type, eval, evaluate,
|
|
resize, console_messages, handle_dialog, file_upload, fill_form, install,
|
|
press_key, network_requests, run_code, drag, hover, select_option, tabs,
|
|
wait_for, pdf, close. Uses refs from snapshot for ref-based actions.
|
|
"""
|
|
|
|
import asyncio
|
|
import atexit
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any, Optional
|
|
|
|
from agentscope.message import TextBlock
|
|
from agentscope.tool import ToolResponse
|
|
|
|
from ...config import (
|
|
get_playwright_chromium_executable_path,
|
|
get_system_default_browser,
|
|
is_running_in_container,
|
|
)
|
|
|
|
from .browser_snapshot import build_role_snapshot_from_aria
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Hybrid mode detection: Windows + Uvicorn reload mode requires sync Playwright
|
|
# to avoid NotImplementedError with asyncio.create_subprocess_exec.
|
|
# On other platforms or without reload, use async Playwright for better performance.
|
|
_USE_SYNC_PLAYWRIGHT = sys.platform == "win32" and os.environ.get("COPAW_RELOAD_MODE") == "1"
|
|
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
_executor: Optional[ThreadPoolExecutor] = None
|
|
|
|
def _get_executor() -> ThreadPoolExecutor:
|
|
global _executor
|
|
if _executor is None:
|
|
_executor = ThreadPoolExecutor(
|
|
max_workers=1,
|
|
thread_name_prefix="playwright",
|
|
)
|
|
return _executor
|
|
|
|
async def _run_sync(func, *args, **kwargs):
|
|
"""Run a sync function in the thread pool and await the result."""
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: func(*args, **kwargs),
|
|
)
|
|
|
|
else:
|
|
|
|
async def _run_sync(func, *args, **kwargs):
|
|
"""Fallback: directly call async function (should not be used in async mode)."""
|
|
return await func(*args, **kwargs)
|
|
|
|
|
|
# Process-global browser state (one browser, multiple pages by page_id)
|
|
_state: dict[str, Any] = {
|
|
"playwright": None,
|
|
"browser": None,
|
|
"context": None,
|
|
"pages": {},
|
|
"refs": {}, # page_id -> ref -> {role, name?, nth?}
|
|
"refs_frame": {}, # page_id -> frame for last snapshot
|
|
"console_logs": {}, # page_id -> list of {level, text}
|
|
"network_requests": {}, # page_id -> list of request dicts
|
|
"pending_dialogs": {}, # page_id -> dialog handlers
|
|
"pending_file_choosers": {}, # page_id -> FileChooser list
|
|
"headless": True,
|
|
"current_page_id": None,
|
|
"page_counter": 0, # monotonic counter for page_N ids, avoids reuse after close
|
|
"last_activity_time": 0.0, # monotonic timestamp of last browser activity
|
|
"_idle_task": None, # background asyncio.Task for idle watchdog
|
|
"_last_browser_error": None, # message when launch failed (for user-facing error)
|
|
"_sync_browser": None, # sync browser handle for hybrid mode
|
|
"_sync_context": None, # sync context handle for hybrid mode
|
|
"_sync_playwright": None, # sync playwright handle for hybrid mode
|
|
}
|
|
|
|
# Stop the browser after this many seconds of inactivity (default 30 minutes).
|
|
_BROWSER_IDLE_TIMEOUT = 1800.0
|
|
|
|
|
|
def _touch_activity() -> None:
|
|
"""Record the current time as the last browser activity timestamp."""
|
|
_state["last_activity_time"] = time.monotonic()
|
|
|
|
|
|
def _is_browser_running() -> bool:
|
|
"""Check if browser is currently running (sync or async mode)."""
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
return _state.get("_sync_browser") is not None
|
|
return _state.get("browser") is not None
|
|
|
|
|
|
def _reset_browser_state() -> None:
|
|
"""Reset all browser-related state variables."""
|
|
# Clear sync/async specific state
|
|
_state["playwright"] = None
|
|
_state["browser"] = None
|
|
_state["context"] = None
|
|
_state["_sync_playwright"] = None
|
|
_state["_sync_browser"] = None
|
|
_state["_sync_context"] = None
|
|
# Clear shared state
|
|
_state["pages"].clear()
|
|
_state["refs"].clear()
|
|
_state["refs_frame"].clear()
|
|
_state["console_logs"].clear()
|
|
_state["network_requests"].clear()
|
|
_state["pending_dialogs"].clear()
|
|
_state["pending_file_choosers"].clear()
|
|
_state["current_page_id"] = None
|
|
_state["page_counter"] = 0
|
|
_state["last_activity_time"] = 0.0
|
|
_state["headless"] = True
|
|
|
|
|
|
async def _idle_watchdog(idle_seconds: float = _BROWSER_IDLE_TIMEOUT) -> None:
|
|
"""Background task: stop the browser after it has been idle for *idle_seconds*.
|
|
|
|
This reclaims Chrome renderer processes that accumulate when pages are
|
|
opened during agent tasks but never explicitly closed.
|
|
"""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(60) # check every minute
|
|
if not _is_browser_running():
|
|
return
|
|
idle = time.monotonic() - _state.get("last_activity_time", 0.0)
|
|
if idle >= idle_seconds:
|
|
logger.info(
|
|
"Browser idle for %.0fs (limit %.0fs), stopping to release resources",
|
|
idle,
|
|
idle_seconds,
|
|
)
|
|
await _action_stop()
|
|
return
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
def _atexit_cleanup() -> None:
|
|
"""Best-effort browser cleanup registered with :func:`atexit`.
|
|
|
|
Playwright child processes are cleaned up by the OS when the parent
|
|
exits, but this gives Playwright a chance to flush any pending I/O and
|
|
close Chrome gracefully before the process disappears.
|
|
"""
|
|
if not _is_browser_running():
|
|
return
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
if not loop.is_running() and not loop.is_closed():
|
|
loop.run_until_complete(_action_stop())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
atexit.register(_atexit_cleanup)
|
|
|
|
|
|
def _tool_response(text: str) -> ToolResponse:
|
|
"""Wrap text for agentscope Toolkit (return ToolResponse)."""
|
|
return ToolResponse(
|
|
content=[TextBlock(type="text", text=text)],
|
|
)
|
|
|
|
|
|
def _chromium_launch_args() -> list[str]:
|
|
"""Extra args for Chromium when running in container."""
|
|
if is_running_in_container():
|
|
return ["--no-sandbox", "--disable-dev-shm-usage"]
|
|
return []
|
|
|
|
|
|
def _chromium_executable_path() -> str | None:
|
|
"""Chromium executable path when set (e.g. container); else None."""
|
|
return get_playwright_chromium_executable_path()
|
|
|
|
|
|
def _use_webkit_fallback() -> bool:
|
|
"""True only on macOS when no system Chrome/Edge/Chromium found.
|
|
Use WebKit (Safari) to avoid downloading Chromium. Windows has no system
|
|
WebKit, so we never use webkit there.
|
|
"""
|
|
return sys.platform == "darwin" and _chromium_executable_path() is None
|
|
|
|
|
|
def _ensure_playwright_async():
|
|
"""Import async_playwright; raise ImportError with hint if missing."""
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
|
|
return async_playwright
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"Playwright not installed. Use the same Python that runs CoPaw (e.g. "
|
|
"activate your venv or use 'uv run'): "
|
|
f"'{sys.executable}' -m pip install playwright && "
|
|
f"'{sys.executable}' -m playwright install",
|
|
) from exc
|
|
|
|
|
|
def _ensure_playwright_sync():
|
|
"""Import sync_playwright; raise ImportError with hint if missing."""
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
return sync_playwright
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"Playwright not installed. Use the same Python that runs CoPaw (e.g. "
|
|
"activate your venv or use 'uv run'): "
|
|
f"'{sys.executable}' -m pip install playwright && "
|
|
f"'{sys.executable}' -m playwright install",
|
|
) from exc
|
|
|
|
|
|
def _sync_browser_launch(headless: bool):
|
|
"""Launch browser using sync Playwright (for hybrid mode)."""
|
|
sync_playwright = _ensure_playwright_sync()
|
|
pw = sync_playwright().start() # Start without context manager
|
|
use_default = not is_running_in_container() and os.environ.get(
|
|
"COPAW_BROWSER_USE_DEFAULT",
|
|
"1",
|
|
).strip().lower() in ("1", "true", "yes")
|
|
default_kind, default_path = get_system_default_browser() if use_default else (None, None)
|
|
exe: Optional[str] = None
|
|
if default_kind == "chromium" and default_path:
|
|
exe = default_path
|
|
elif default_kind != "webkit":
|
|
exe = _chromium_executable_path()
|
|
|
|
if exe:
|
|
launch_kwargs = {"headless": headless}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
launch_kwargs["executable_path"] = exe
|
|
browser = pw.chromium.launch(**launch_kwargs)
|
|
elif default_kind == "webkit" or sys.platform == "darwin":
|
|
browser = pw.webkit.launch(headless=headless)
|
|
else:
|
|
launch_kwargs = {"headless": headless}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
browser = pw.chromium.launch(**launch_kwargs)
|
|
|
|
context = browser.new_context()
|
|
_attach_context_listeners(context)
|
|
return pw, browser, context
|
|
|
|
|
|
def _sync_browser_close():
|
|
"""Close browser using sync Playwright (for hybrid mode)."""
|
|
if _state["_sync_browser"] is not None:
|
|
try:
|
|
_state["_sync_browser"].close()
|
|
except Exception:
|
|
pass
|
|
if _state["_sync_playwright"] is not None:
|
|
try:
|
|
_state["_sync_playwright"].stop()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _parse_json_param(value: str, default: Any = None):
|
|
"""Parse optional JSON string param (e.g. fields, paths, values)."""
|
|
if not value or not isinstance(value, str):
|
|
return default
|
|
value = value.strip()
|
|
if not value:
|
|
return default
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
if "," in value:
|
|
return [x.strip() for x in value.split(",")]
|
|
return default
|
|
|
|
|
|
async def browser_use( # pylint: disable=R0911,R0912
|
|
action: str,
|
|
url: str = "",
|
|
page_id: str = "default",
|
|
selector: str = "",
|
|
text: str = "",
|
|
code: str = "",
|
|
path: str = "",
|
|
wait: int = 0,
|
|
full_page: bool = False,
|
|
width: int = 0,
|
|
height: int = 0,
|
|
level: str = "info",
|
|
filename: str = "",
|
|
accept: bool = True,
|
|
prompt_text: str = "",
|
|
ref: str = "",
|
|
element: str = "",
|
|
paths_json: str = "",
|
|
fields_json: str = "",
|
|
key: str = "",
|
|
submit: bool = False,
|
|
slowly: bool = False,
|
|
include_static: bool = False,
|
|
screenshot_type: str = "png",
|
|
snapshot_filename: str = "",
|
|
double_click: bool = False,
|
|
button: str = "left",
|
|
modifiers_json: str = "",
|
|
start_ref: str = "",
|
|
end_ref: str = "",
|
|
start_selector: str = "",
|
|
end_selector: str = "",
|
|
start_element: str = "",
|
|
end_element: str = "",
|
|
values_json: str = "",
|
|
tab_action: str = "",
|
|
index: int = -1,
|
|
wait_time: float = 0,
|
|
text_gone: str = "",
|
|
frame_selector: str = "",
|
|
headed: bool = False,
|
|
) -> ToolResponse:
|
|
"""Control browser (Playwright). Default is headless. Use headed=True with
|
|
action=start to open a visible browser window. Flow: start, open(url),
|
|
snapshot to get refs, then click/type etc. with ref or selector. Use
|
|
page_id for multiple tabs.
|
|
|
|
Args:
|
|
action (str):
|
|
Required. Action type. Values: start, stop, open, navigate,
|
|
navigate_back, snapshot, screenshot, click, type, eval, evaluate,
|
|
resize, console_messages, network_requests, handle_dialog,
|
|
file_upload, fill_form, install, press_key, run_code, drag, hover,
|
|
select_option, tabs, wait_for, pdf, close.
|
|
url (str):
|
|
URL to open. Required for action=open or navigate.
|
|
page_id (str):
|
|
Page/tab identifier, default "default". Use different page_id for
|
|
multiple tabs.
|
|
selector (str):
|
|
CSS selector to locate element for click/type/hover etc. Prefer
|
|
ref when available.
|
|
text (str):
|
|
Text to type. Required for action=type.
|
|
code (str):
|
|
JavaScript code. Required for action=eval, evaluate, or run_code.
|
|
path (str):
|
|
File path for screenshot save or PDF export.
|
|
wait (int):
|
|
Milliseconds to wait after click. Used with action=click.
|
|
full_page (bool):
|
|
Whether to capture full page. Used with action=screenshot.
|
|
width (int):
|
|
Viewport width in pixels. Used with action=resize.
|
|
height (int):
|
|
Viewport height in pixels. Used with action=resize.
|
|
level (str):
|
|
Console log level filter, e.g. "info" or "error". Used with
|
|
action=console_messages.
|
|
filename (str):
|
|
Filename for saving logs or screenshot. Used with
|
|
console_messages, network_requests, screenshot.
|
|
accept (bool):
|
|
Whether to accept dialog (true) or dismiss (false). Used with
|
|
action=handle_dialog.
|
|
prompt_text (str):
|
|
Input for prompt dialog. Used with action=handle_dialog when
|
|
dialog is prompt.
|
|
ref (str):
|
|
Element ref from snapshot output; use for stable targeting. Prefer
|
|
ref for click/type/hover/screenshot/evaluate/select_option.
|
|
element (str):
|
|
Element description for evaluate etc. Prefer ref when available.
|
|
paths_json (str):
|
|
JSON array string of file paths. Used with action=file_upload.
|
|
fields_json (str):
|
|
JSON object string of form field name to value. Used with
|
|
action=fill_form.
|
|
key (str):
|
|
Key name, e.g. "Enter", "Control+a". Required for
|
|
action=press_key.
|
|
submit (bool):
|
|
Whether to submit (press Enter) after typing. Used with
|
|
action=type.
|
|
slowly (bool):
|
|
Whether to type character by character. Used with action=type.
|
|
include_static (bool):
|
|
Whether to include static resource requests. Used with
|
|
action=network_requests.
|
|
screenshot_type (str):
|
|
Screenshot format, "png" or "jpeg". Used with action=screenshot.
|
|
snapshot_filename (str):
|
|
File path to save snapshot output. Used with action=snapshot.
|
|
double_click (bool):
|
|
Whether to double-click. Used with action=click.
|
|
button (str):
|
|
Mouse button: "left", "right", or "middle". Used with
|
|
action=click.
|
|
modifiers_json (str):
|
|
JSON array of modifier keys, e.g. ["Shift","Control"]. Used with
|
|
action=click.
|
|
start_ref (str):
|
|
Drag start element ref. Used with action=drag.
|
|
end_ref (str):
|
|
Drag end element ref. Used with action=drag.
|
|
start_selector (str):
|
|
Drag start CSS selector. Used with action=drag.
|
|
end_selector (str):
|
|
Drag end CSS selector. Used with action=drag.
|
|
start_element (str):
|
|
Drag start element description. Used with action=drag.
|
|
end_element (str):
|
|
Drag end element description. Used with action=drag.
|
|
values_json (str):
|
|
JSON of option value(s) for select. Used with
|
|
action=select_option.
|
|
tab_action (str):
|
|
Tab action: list, new, close, or select. Required for
|
|
action=tabs.
|
|
index (int):
|
|
Tab index for tabs select, zero-based. Used with action=tabs.
|
|
wait_time (float):
|
|
Seconds to wait. Used with action=wait_for.
|
|
text_gone (str):
|
|
Wait until this text disappears from page. Used with
|
|
action=wait_for.
|
|
frame_selector (str):
|
|
iframe selector, e.g. "iframe#main". Set when operating inside
|
|
that iframe in snapshot/click/type etc.
|
|
headed (bool):
|
|
When True with action=start, launch a visible browser window
|
|
(non-headless). User can see the real browser. Default False.
|
|
"""
|
|
action = (action or "").strip().lower()
|
|
if not action:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "action required"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
page_id = (page_id or "default").strip() or "default"
|
|
current = _state.get("current_page_id")
|
|
pages = _state.get("pages") or {}
|
|
if page_id == "default" and current and current in pages:
|
|
page_id = current
|
|
|
|
try:
|
|
if action == "start":
|
|
return await _action_start(headed=headed)
|
|
if action == "stop":
|
|
return await _action_stop()
|
|
if action == "open":
|
|
return await _action_open(url, page_id)
|
|
if action == "navigate":
|
|
return await _action_navigate(url, page_id)
|
|
if action == "navigate_back":
|
|
return await _action_navigate_back(page_id)
|
|
if action in ("screenshot", "take_screenshot"):
|
|
return await _action_screenshot(
|
|
page_id,
|
|
path or filename,
|
|
full_page,
|
|
screenshot_type,
|
|
ref,
|
|
element,
|
|
frame_selector,
|
|
)
|
|
if action == "snapshot":
|
|
return await _action_snapshot(
|
|
page_id,
|
|
snapshot_filename or filename,
|
|
frame_selector,
|
|
)
|
|
if action == "click":
|
|
return await _action_click(
|
|
page_id,
|
|
selector,
|
|
ref,
|
|
element,
|
|
wait,
|
|
double_click,
|
|
button,
|
|
modifiers_json,
|
|
frame_selector,
|
|
)
|
|
if action == "type":
|
|
return await _action_type(
|
|
page_id,
|
|
selector,
|
|
ref,
|
|
element,
|
|
text,
|
|
submit,
|
|
slowly,
|
|
frame_selector,
|
|
)
|
|
if action == "eval":
|
|
return await _action_eval(page_id, code)
|
|
if action == "evaluate":
|
|
return await _action_evaluate(
|
|
page_id,
|
|
code,
|
|
ref,
|
|
element,
|
|
frame_selector,
|
|
)
|
|
if action == "resize":
|
|
return await _action_resize(page_id, width, height)
|
|
if action == "console_messages":
|
|
return await _action_console_messages(
|
|
page_id,
|
|
level,
|
|
filename or path,
|
|
)
|
|
if action == "handle_dialog":
|
|
return await _action_handle_dialog(page_id, accept, prompt_text)
|
|
if action == "file_upload":
|
|
return await _action_file_upload(page_id, paths_json)
|
|
if action == "fill_form":
|
|
return await _action_fill_form(page_id, fields_json)
|
|
if action == "install":
|
|
return await _action_install()
|
|
if action == "press_key":
|
|
return await _action_press_key(page_id, key)
|
|
if action == "network_requests":
|
|
return await _action_network_requests(
|
|
page_id,
|
|
include_static,
|
|
filename or path,
|
|
)
|
|
if action == "run_code":
|
|
return await _action_run_code(page_id, code)
|
|
if action == "drag":
|
|
return await _action_drag(
|
|
page_id,
|
|
start_ref,
|
|
end_ref,
|
|
start_selector,
|
|
end_selector,
|
|
start_element,
|
|
end_element,
|
|
frame_selector,
|
|
)
|
|
if action == "hover":
|
|
return await _action_hover(
|
|
page_id,
|
|
ref,
|
|
element,
|
|
selector,
|
|
frame_selector,
|
|
)
|
|
if action == "select_option":
|
|
return await _action_select_option(
|
|
page_id,
|
|
ref,
|
|
element,
|
|
values_json,
|
|
frame_selector,
|
|
)
|
|
if action == "tabs":
|
|
return await _action_tabs(page_id, tab_action, index)
|
|
if action == "wait_for":
|
|
return await _action_wait_for(page_id, wait_time, text, text_gone)
|
|
if action == "pdf":
|
|
return await _action_pdf(page_id, path)
|
|
if action == "close":
|
|
return await _action_close(page_id)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown action: {action}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Browser tool error: %s", e, exc_info=True)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": str(e)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
def _get_page(page_id: str):
|
|
"""Return page for page_id or None if not found."""
|
|
return _state["pages"].get(page_id)
|
|
|
|
|
|
def _get_refs(page_id: str) -> dict[str, dict]:
|
|
"""Return refs map for page_id (ref -> {role, name?, nth?})."""
|
|
return _state["refs"].setdefault(page_id, {})
|
|
|
|
|
|
def _get_root(page, _page_id: str, frame_selector: str = ""):
|
|
"""Return page or frame for frame_selector (ref/selector)."""
|
|
if not (frame_selector and frame_selector.strip()):
|
|
return page
|
|
return page.frame_locator(frame_selector.strip())
|
|
|
|
|
|
def _get_locator_by_ref(
|
|
page,
|
|
page_id: str,
|
|
ref: str,
|
|
frame_selector: str = "",
|
|
):
|
|
"""Resolve snapshot ref to locator; frame_selector for iframe."""
|
|
refs = _get_refs(page_id)
|
|
info = refs.get(ref)
|
|
if not info:
|
|
return None
|
|
role = info.get("role", "generic")
|
|
name = info.get("name")
|
|
nth = info.get("nth", 0)
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.get_by_role(role, name=name or None)
|
|
if nth is not None and nth > 0:
|
|
locator = locator.nth(nth)
|
|
return locator
|
|
|
|
|
|
def _attach_page_listeners(page, page_id: str) -> None:
|
|
"""Attach console and request listeners for a page."""
|
|
logs = _state["console_logs"].setdefault(page_id, [])
|
|
|
|
def on_console(msg):
|
|
logs.append({"level": msg.type, "text": msg.text})
|
|
|
|
page.on("console", on_console)
|
|
requests_list = _state["network_requests"].setdefault(page_id, [])
|
|
|
|
def on_request(req):
|
|
requests_list.append(
|
|
{
|
|
"url": req.url,
|
|
"method": req.method,
|
|
"resourceType": getattr(req, "resource_type", None),
|
|
},
|
|
)
|
|
|
|
def on_response(res):
|
|
for r in requests_list:
|
|
if r.get("url") == res.url and "status" not in r:
|
|
r["status"] = res.status
|
|
break
|
|
|
|
page.on("request", on_request)
|
|
page.on("response", on_response)
|
|
dialogs = _state["pending_dialogs"].setdefault(page_id, [])
|
|
|
|
def on_dialog(dialog):
|
|
dialogs.append(dialog)
|
|
|
|
page.on("dialog", on_dialog)
|
|
choosers = _state["pending_file_choosers"].setdefault(page_id, [])
|
|
|
|
def on_filechooser(chooser):
|
|
choosers.append(chooser)
|
|
|
|
page.on("filechooser", on_filechooser)
|
|
|
|
|
|
def _next_page_id() -> str:
|
|
"""Return a unique page_id (page_N).
|
|
Uses monotonic counter so IDs are not reused after close."""
|
|
_state["page_counter"] = _state.get("page_counter", 0) + 1
|
|
return f"page_{_state['page_counter']}"
|
|
|
|
|
|
def _attach_context_listeners(context) -> None:
|
|
"""When the page opens a new tab (e.g. target=_blank, window.open),
|
|
register it and set as current."""
|
|
|
|
def on_page(page):
|
|
new_id = _next_page_id()
|
|
_state["refs"][new_id] = {}
|
|
_state["console_logs"][new_id] = []
|
|
_state["network_requests"][new_id] = []
|
|
_state["pending_dialogs"][new_id] = []
|
|
_state["pending_file_choosers"][new_id] = []
|
|
_attach_page_listeners(page, new_id)
|
|
_state["pages"][new_id] = page
|
|
_state["current_page_id"] = new_id
|
|
logger.debug(
|
|
"New tab opened by page, registered as page_id=%s",
|
|
new_id,
|
|
)
|
|
|
|
context.on("page", on_page)
|
|
|
|
|
|
async def _ensure_browser() -> bool: # pylint: disable=too-many-branches
|
|
"""Start browser if not running. Return True if ready, False on failure."""
|
|
# Check browser state based on mode
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
if _state["_sync_browser"] is not None and _state["_sync_context"] is not None:
|
|
_touch_activity()
|
|
return True
|
|
else:
|
|
if _state["browser"] is not None and _state["context"] is not None:
|
|
_touch_activity()
|
|
return True
|
|
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
# Hybrid mode: use sync Playwright in thread pool
|
|
loop = asyncio.get_event_loop()
|
|
pw, browser, context = await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: _sync_browser_launch(_state["headless"]),
|
|
)
|
|
_state["_sync_playwright"] = pw
|
|
_state["_sync_browser"] = browser
|
|
_state["_sync_context"] = context
|
|
else:
|
|
# Standard mode: use async Playwright
|
|
async_playwright = _ensure_playwright_async()
|
|
pw = await async_playwright().start()
|
|
# Prefer OS default browser when available (e.g. user's default Chrome/Safari).
|
|
use_default = not is_running_in_container() and os.environ.get(
|
|
"COPAW_BROWSER_USE_DEFAULT",
|
|
"1",
|
|
).strip().lower() in ("1", "true", "yes")
|
|
default_kind, default_path = get_system_default_browser() if use_default else (None, None)
|
|
exe: Optional[str] = None
|
|
if default_kind == "chromium" and default_path:
|
|
exe = default_path
|
|
elif default_kind != "webkit":
|
|
exe = _chromium_executable_path()
|
|
if exe:
|
|
# System Chrome/Edge/Chromium (default or discovered)
|
|
launch_kwargs: dict[str, Any] = {
|
|
"headless": _state["headless"],
|
|
}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
launch_kwargs["executable_path"] = exe
|
|
pw_browser = await pw.chromium.launch(**launch_kwargs)
|
|
elif default_kind == "webkit" or sys.platform == "darwin":
|
|
# macOS: default Safari or no Chromium → use WebKit (Safari)
|
|
pw_browser = await pw.webkit.launch(
|
|
headless=_state["headless"],
|
|
)
|
|
else:
|
|
# Windows/Linux without system Chromium → Playwright's Chromium
|
|
launch_kwargs = {"headless": _state["headless"]}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
pw_browser = await pw.chromium.launch(**launch_kwargs)
|
|
context = await pw_browser.new_context()
|
|
_attach_context_listeners(context)
|
|
_state["playwright"] = pw
|
|
_state["browser"] = pw_browser
|
|
_state["context"] = context
|
|
_state["_last_browser_error"] = None
|
|
_touch_activity()
|
|
_start_idle_watchdog()
|
|
return True
|
|
except Exception as e:
|
|
_state["_last_browser_error"] = str(e)
|
|
return False
|
|
|
|
|
|
def _start_idle_watchdog() -> None:
|
|
"""Cancel any existing idle watchdog and start a fresh one."""
|
|
old_task = _state.get("_idle_task")
|
|
if old_task and not old_task.done():
|
|
old_task.cancel()
|
|
_state["_idle_task"] = asyncio.ensure_future(_idle_watchdog())
|
|
|
|
|
|
def _cancel_idle_watchdog() -> None:
|
|
"""Cancel the idle watchdog, if running."""
|
|
task = _state.get("_idle_task")
|
|
if task and not task.done():
|
|
task.cancel()
|
|
_state["_idle_task"] = None
|
|
|
|
|
|
# pylint: disable=R0912,R0915
|
|
async def _action_start(
|
|
headed: bool = False,
|
|
) -> ToolResponse:
|
|
# Check browser state based on mode
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
browser_exists = _state["_sync_browser"] is not None
|
|
current_headless = not _state.get("_sync_headless", True)
|
|
else:
|
|
browser_exists = _state["browser"] is not None
|
|
current_headless = _state["headless"]
|
|
|
|
# If user asks for visible window (headed=True)
|
|
# but browser is already running headless, restart with headed
|
|
if browser_exists:
|
|
if headed and current_headless:
|
|
_cancel_idle_watchdog()
|
|
try:
|
|
await _action_stop()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Browser already running"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
# Default: headless (background). Only headed=True (e.g. browser_visible skill) shows window.
|
|
_state["headless"] = not headed
|
|
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
pw, browser, context = await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: _sync_browser_launch(_state["headless"]),
|
|
)
|
|
_state["_sync_playwright"] = pw
|
|
_state["_sync_browser"] = browser
|
|
_state["_sync_context"] = context
|
|
_state["_sync_headless"] = not headed
|
|
else:
|
|
async_playwright = _ensure_playwright_async()
|
|
pw = await async_playwright().start()
|
|
use_default = not is_running_in_container() and os.environ.get(
|
|
"COPAW_BROWSER_USE_DEFAULT",
|
|
"1",
|
|
).strip().lower() in ("1", "true", "yes")
|
|
default_kind, default_path = get_system_default_browser() if use_default else (None, None)
|
|
exe: Optional[str] = None
|
|
if default_kind == "chromium" and default_path:
|
|
exe = default_path
|
|
elif default_kind != "webkit":
|
|
exe = _chromium_executable_path()
|
|
if exe:
|
|
launch_kwargs = {"headless": _state["headless"]}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
launch_kwargs["executable_path"] = exe
|
|
pw_browser = await pw.chromium.launch(**launch_kwargs)
|
|
elif default_kind == "webkit" or sys.platform == "darwin":
|
|
pw_browser = await pw.webkit.launch(
|
|
headless=_state["headless"],
|
|
)
|
|
else:
|
|
launch_kwargs = {"headless": _state["headless"]}
|
|
extra_args = _chromium_launch_args()
|
|
if extra_args:
|
|
launch_kwargs["args"] = extra_args
|
|
pw_browser = await pw.chromium.launch(**launch_kwargs)
|
|
context = await pw_browser.new_context()
|
|
_attach_context_listeners(context)
|
|
_state["playwright"] = pw
|
|
_state["browser"] = pw_browser
|
|
_state["context"] = context
|
|
_touch_activity()
|
|
_start_idle_watchdog()
|
|
msg = "Browser started (visible window)" if not _state["headless"] else "Browser started"
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": msg},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Browser start failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_stop() -> ToolResponse:
|
|
_cancel_idle_watchdog()
|
|
|
|
# Check browser state based on mode
|
|
if not _is_browser_running():
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Browser not running"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
# Close sync browser in thread pool
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
_sync_browser_close,
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Browser stop failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
finally:
|
|
_reset_browser_state()
|
|
else:
|
|
# Standard async mode
|
|
try:
|
|
await _state["browser"].close()
|
|
if _state["playwright"] is not None:
|
|
await _state["playwright"].stop()
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Browser stop failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
finally:
|
|
_reset_browser_state()
|
|
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Browser stopped"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_open(url: str, page_id: str) -> ToolResponse:
|
|
url = (url or "").strip()
|
|
if not url:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "url required for open"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if not await _ensure_browser():
|
|
err = _state.get("_last_browser_error") or "Browser not started"
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": err},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
# Hybrid mode: create page in thread pool
|
|
loop = asyncio.get_event_loop()
|
|
# pylint: disable=unnecessary-lambda
|
|
page = await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: _state["_sync_context"].new_page(),
|
|
)
|
|
else:
|
|
# Standard async mode
|
|
page = await _state["context"].new_page()
|
|
|
|
_state["refs"][page_id] = {}
|
|
_state["console_logs"][page_id] = []
|
|
_state["network_requests"][page_id] = []
|
|
_state["pending_dialogs"][page_id] = []
|
|
_state["pending_file_choosers"][page_id] = []
|
|
_attach_page_listeners(page, page_id)
|
|
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: page.goto(url),
|
|
)
|
|
else:
|
|
await page.goto(url)
|
|
|
|
_state["pages"][page_id] = page
|
|
_state["current_page_id"] = page_id
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Opened {url}",
|
|
"page_id": page_id,
|
|
"url": url,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Open failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_navigate(url: str, page_id: str) -> ToolResponse:
|
|
url = (url or "").strip()
|
|
if not url:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "url required for navigate"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: page.goto(url),
|
|
)
|
|
else:
|
|
await page.goto(url)
|
|
_state["current_page_id"] = page_id
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Navigated to {url}",
|
|
"url": page.url,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Navigate failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_screenshot(
|
|
page_id: str,
|
|
path: str,
|
|
full_page: bool,
|
|
screenshot_type: str = "png",
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
path = (path or "").strip()
|
|
if not path:
|
|
ext = "jpeg" if screenshot_type == "jpeg" else "png"
|
|
path = f"page-{int(time.time())}.{ext}"
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if ref and ref.strip():
|
|
locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
ref.strip(),
|
|
frame_selector,
|
|
)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.screenshot,
|
|
path=path,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
else:
|
|
await locator.screenshot(
|
|
path=path,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
else:
|
|
if frame_selector and frame_selector.strip():
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator("body").first
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.screenshot,
|
|
path=path,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
else:
|
|
await locator.screenshot(
|
|
path=path,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
page.screenshot,
|
|
path=path,
|
|
full_page=full_page,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
else:
|
|
await page.screenshot(
|
|
path=path,
|
|
full_page=full_page,
|
|
type=screenshot_type if screenshot_type == "jpeg" else "png",
|
|
)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Screenshot saved to {path}",
|
|
"path": path,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Screenshot failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_click( # pylint: disable=too-many-branches
|
|
page_id: str,
|
|
selector: str,
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
wait: int = 0,
|
|
double_click: bool = False,
|
|
button: str = "left",
|
|
modifiers_json: str = "",
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
ref = (ref or "").strip()
|
|
selector = (selector or "").strip()
|
|
if not ref and not selector:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "selector or ref required for click"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if wait > 0:
|
|
await asyncio.sleep(wait / 1000.0)
|
|
mods = _parse_json_param(modifiers_json, [])
|
|
if not isinstance(mods, list):
|
|
mods = []
|
|
kwargs = {
|
|
"button": button if button in ("left", "right", "middle") else "left",
|
|
}
|
|
if mods:
|
|
kwargs["modifiers"] = [m for m in mods if m in ("Alt", "Control", "ControlOrMeta", "Meta", "Shift")]
|
|
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
if ref:
|
|
locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
ref,
|
|
frame_selector,
|
|
)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if double_click:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.dblclick(**kwargs),
|
|
)
|
|
else:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.click(**kwargs),
|
|
)
|
|
else:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator(selector).first
|
|
if double_click:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.dblclick(**kwargs),
|
|
)
|
|
else:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.click(**kwargs),
|
|
)
|
|
else:
|
|
# Standard async mode
|
|
if ref:
|
|
locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
ref,
|
|
frame_selector,
|
|
)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if double_click:
|
|
await locator.dblclick(**kwargs)
|
|
else:
|
|
await locator.click(**kwargs)
|
|
else:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator(selector).first
|
|
if double_click:
|
|
await locator.dblclick(**kwargs)
|
|
else:
|
|
await locator.click(**kwargs)
|
|
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Clicked {ref or selector}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Click failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_type(
|
|
page_id: str,
|
|
selector: str,
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
text: str = "",
|
|
submit: bool = False,
|
|
slowly: bool = False,
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
ref = (ref or "").strip()
|
|
selector = (selector or "").strip()
|
|
if not ref and not selector:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "selector or ref required for type"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if ref:
|
|
locator = _get_locator_by_ref(page, page_id, ref, frame_selector)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
if slowly:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.press_sequentially(text or ""),
|
|
)
|
|
else:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.fill(text or ""),
|
|
)
|
|
if submit:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.press("Enter"),
|
|
)
|
|
else:
|
|
if slowly:
|
|
await locator.press_sequentially(text or "")
|
|
else:
|
|
await locator.fill(text or "")
|
|
if submit:
|
|
await locator.press("Enter")
|
|
else:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
loc = root.locator(selector).first
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
loop = asyncio.get_event_loop()
|
|
if slowly:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: loc.press_sequentially(text or ""),
|
|
)
|
|
else:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: loc.fill(text or ""),
|
|
)
|
|
if submit:
|
|
await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: loc.press("Enter"),
|
|
)
|
|
else:
|
|
if slowly:
|
|
await loc.press_sequentially(text or "")
|
|
else:
|
|
await loc.fill(text or "")
|
|
if submit:
|
|
await loc.press("Enter")
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Typed into {ref or selector}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Type failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_eval(page_id: str, code: str) -> ToolResponse:
|
|
code = (code or "").strip()
|
|
if not code:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "code required for eval"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if code.strip().startswith("(") or code.strip().startswith("function"):
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(page.evaluate, code)
|
|
else:
|
|
result = await page.evaluate(code)
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(
|
|
page.evaluate,
|
|
f"() => {{ return ({code}); }}",
|
|
)
|
|
else:
|
|
result = await page.evaluate(f"() => {{ return ({code}); }}")
|
|
try:
|
|
out = json.dumps(
|
|
{"ok": True, "result": result},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
except TypeError:
|
|
out = json.dumps(
|
|
{"ok": True, "result": str(result)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
return _tool_response(out)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Eval failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_pdf(page_id: str, path: str) -> ToolResponse:
|
|
path = (path or "page.pdf").strip() or "page.pdf"
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(page.pdf, path=path)
|
|
else:
|
|
await page.pdf(path=path)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"PDF saved to {path}", "path": path},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"PDF failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_close(page_id: str) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(page.close)
|
|
else:
|
|
await page.close()
|
|
del _state["pages"][page_id]
|
|
for key in (
|
|
"refs",
|
|
"refs_frame",
|
|
"console_logs",
|
|
"network_requests",
|
|
"pending_dialogs",
|
|
"pending_file_choosers",
|
|
):
|
|
_state[key].pop(page_id, None)
|
|
if _state.get("current_page_id") == page_id:
|
|
remaining = list(_state["pages"].keys())
|
|
_state["current_page_id"] = remaining[0] if remaining else None
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Closed page '{page_id}'"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Close failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_snapshot(
|
|
page_id: str,
|
|
filename: str,
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
# Hybrid mode: execute in thread pool
|
|
loop = asyncio.get_event_loop()
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator(":root")
|
|
raw = await loop.run_in_executor(
|
|
_get_executor(),
|
|
lambda: locator.aria_snapshot(), # pylint: disable=unnecessary-lambda
|
|
)
|
|
else:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator(":root")
|
|
raw = await locator.aria_snapshot()
|
|
|
|
raw_str = str(raw) if raw is not None else ""
|
|
snapshot, refs = build_role_snapshot_from_aria(
|
|
raw_str,
|
|
interactive=False,
|
|
compact=False,
|
|
)
|
|
_state["refs"][page_id] = refs
|
|
_state["refs_frame"][page_id] = frame_selector.strip() if frame_selector else ""
|
|
out = {
|
|
"ok": True,
|
|
"snapshot": snapshot,
|
|
"refs": list(refs.keys()),
|
|
"url": page.url,
|
|
}
|
|
if frame_selector and frame_selector.strip():
|
|
out["frame_selector"] = frame_selector.strip()
|
|
if filename and filename.strip():
|
|
with open(filename.strip(), "w", encoding="utf-8") as f:
|
|
f.write(snapshot)
|
|
out["filename"] = filename.strip()
|
|
return _tool_response(json.dumps(out, ensure_ascii=False, indent=2))
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Snapshot failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_navigate_back(page_id: str) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(page.go_back)
|
|
else:
|
|
await page.go_back()
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Navigated back", "url": page.url},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Navigate back failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_evaluate(
|
|
page_id: str,
|
|
code: str,
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
code = (code or "").strip()
|
|
if not code:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "code required for evaluate"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if ref and ref.strip():
|
|
locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
ref.strip(),
|
|
frame_selector,
|
|
)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(locator.evaluate, code)
|
|
else:
|
|
result = await locator.evaluate(code)
|
|
else:
|
|
if code.strip().startswith("(") or code.strip().startswith(
|
|
"function",
|
|
):
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(page.evaluate, code)
|
|
else:
|
|
result = await page.evaluate(code)
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(
|
|
page.evaluate,
|
|
f"() => {{ return ({code}); }}",
|
|
)
|
|
else:
|
|
result = await page.evaluate(
|
|
f"() => {{ return ({code}); }}",
|
|
)
|
|
try:
|
|
out = json.dumps(
|
|
{"ok": True, "result": result},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
except TypeError:
|
|
out = json.dumps(
|
|
{"ok": True, "result": str(result)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
return _tool_response(out)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Evaluate failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_resize(
|
|
page_id: str,
|
|
width: int,
|
|
height: int,
|
|
) -> ToolResponse:
|
|
if width <= 0 or height <= 0:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "width and height must be positive"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
page.set_viewport_size,
|
|
{"width": width, "height": height},
|
|
)
|
|
else:
|
|
await page.set_viewport_size({"width": width, "height": height})
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Resized to {width}x{height}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Resize failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_console_messages(
|
|
page_id: str,
|
|
level: str,
|
|
filename: str,
|
|
) -> ToolResponse:
|
|
level = (level or "info").strip().lower()
|
|
order = ("error", "warning", "info", "debug")
|
|
idx = order.index(level) if level in order else 2
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
logs = _state["console_logs"].get(page_id, [])
|
|
filtered = [m for m in logs if order.index(m["level"]) <= idx] if level in order else logs
|
|
lines = [f"[{m['level']}] {m['text']}" for m in filtered]
|
|
text = "\n".join(lines)
|
|
if filename and filename.strip():
|
|
with open(filename.strip(), "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Console messages saved to {filename}",
|
|
"filename": filename.strip(),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "messages": filtered, "text": text},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_handle_dialog(
|
|
page_id: str,
|
|
accept: bool,
|
|
prompt_text: str,
|
|
) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
dialogs = _state["pending_dialogs"].get(page_id, [])
|
|
if not dialogs:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "No pending dialog"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
dialog = dialogs.pop(0)
|
|
if accept:
|
|
if prompt_text and hasattr(dialog, "accept"):
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(dialog.accept, prompt_text)
|
|
else:
|
|
await dialog.accept(prompt_text)
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(dialog.accept)
|
|
else:
|
|
await dialog.accept()
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(dialog.dismiss)
|
|
else:
|
|
await dialog.dismiss()
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Dialog handled"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Handle dialog failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_file_upload(page_id: str, paths_json: str) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
paths = _parse_json_param(paths_json, [])
|
|
if not isinstance(paths, list):
|
|
paths = []
|
|
try:
|
|
choosers = _state["pending_file_choosers"].get(page_id, [])
|
|
if not choosers:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": "No chooser. Click upload then file_upload.",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
chooser = choosers.pop(0)
|
|
if paths:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(chooser.set_files, paths)
|
|
else:
|
|
await chooser.set_files(paths)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Uploaded {len(paths)} file(s)"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(chooser.set_files, [])
|
|
else:
|
|
await chooser.set_files([])
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "File chooser cancelled"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"File upload failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_fill_form(page_id: str, fields_json: str) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
fields = _parse_json_param(fields_json, [])
|
|
if not isinstance(fields, list) or not fields:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "fields required (JSON array)"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
refs = _get_refs(page_id)
|
|
# Use last snapshot's frame so fill_form works after iframe snapshot
|
|
frame = _state["refs_frame"].get(page_id, "")
|
|
try:
|
|
for f in fields:
|
|
ref = (f.get("ref") or "").strip()
|
|
if not ref or ref not in refs:
|
|
continue
|
|
locator = _get_locator_by_ref(page, page_id, ref, frame)
|
|
if locator is None:
|
|
continue
|
|
field_type = (f.get("type") or "textbox").lower()
|
|
value = f.get("value")
|
|
if field_type == "checkbox":
|
|
if isinstance(value, str):
|
|
value = value.strip().lower() in ("true", "1", "yes")
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(locator.set_checked, bool(value))
|
|
else:
|
|
await locator.set_checked(bool(value))
|
|
elif field_type == "radio":
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(locator.set_checked, True)
|
|
else:
|
|
await locator.set_checked(True)
|
|
elif field_type == "combobox":
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.select_option,
|
|
label=value if isinstance(value, str) else None,
|
|
value=value,
|
|
)
|
|
else:
|
|
await locator.select_option(
|
|
label=value if isinstance(value, str) else None,
|
|
value=value,
|
|
)
|
|
elif field_type == "slider":
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(locator.fill, str(value))
|
|
else:
|
|
await locator.fill(str(value))
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.fill,
|
|
str(value) if value is not None else "",
|
|
)
|
|
else:
|
|
await locator.fill(str(value) if value is not None else "")
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Filled {len(fields)} field(s)"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Fill form failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
def _run_playwright_install() -> None:
|
|
"""Run playwright install in a blocking way (for use in thread)."""
|
|
subprocess.run(
|
|
[sys.executable, "-m", "playwright", "install"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600, # 10 minutes max
|
|
)
|
|
|
|
|
|
async def _action_install() -> ToolResponse:
|
|
"""Install Playwright browsers. If a system Chrome/Chromium/Edge is found,
|
|
use it and skip download. On macOS with no Chromium, use Safari (WebKit)
|
|
so no download is needed. Only run playwright install when necessary.
|
|
"""
|
|
exe = _chromium_executable_path()
|
|
if exe:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Using system browser (no download): {exe}",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _use_webkit_fallback():
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": "On macOS using Safari (WebKit); no browser download needed.",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
await asyncio.to_thread(_run_playwright_install)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Browser installed"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": "Browser install timed out (10 min). Run manually in terminal: "
|
|
f"{sys.executable!s} -m playwright install",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": f"Install failed: {e!s}. Install manually: "
|
|
f"{sys.executable!s} -m pip install playwright && "
|
|
f"{sys.executable!s} -m playwright install",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_press_key(page_id: str, key: str) -> ToolResponse:
|
|
key = (key or "").strip()
|
|
if not key:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "key required for press_key"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(page.keyboard.press, key)
|
|
else:
|
|
await page.keyboard.press(key)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Pressed key {key}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Press key failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_network_requests(
|
|
page_id: str,
|
|
include_static: bool,
|
|
filename: str,
|
|
) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
requests = _state["network_requests"].get(page_id, [])
|
|
if not include_static:
|
|
static = ("image", "stylesheet", "font", "media")
|
|
requests = [r for r in requests if r.get("resourceType") not in static]
|
|
lines = [f"{r.get('method', '')} {r.get('url', '')} {r.get('status', '')}" for r in requests]
|
|
text = "\n".join(lines)
|
|
if filename and filename.strip():
|
|
with open(filename.strip(), "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Network requests saved to {filename}",
|
|
"filename": filename.strip(),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "requests": requests, "text": text},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_run_code(page_id: str, code: str) -> ToolResponse:
|
|
"""Run JS in page (like eval). Use evaluate for element (ref)."""
|
|
code = (code or "").strip()
|
|
if not code:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "code required for run_code"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if code.strip().startswith("(") or code.strip().startswith("function"):
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(page.evaluate, code)
|
|
else:
|
|
result = await page.evaluate(code)
|
|
else:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
result = await _run_sync(
|
|
page.evaluate,
|
|
f"() => {{ return ({code}); }}",
|
|
)
|
|
else:
|
|
result = await page.evaluate(f"() => {{ return ({code}); }}")
|
|
try:
|
|
out = json.dumps(
|
|
{"ok": True, "result": result},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
except TypeError:
|
|
out = json.dumps(
|
|
{"ok": True, "result": str(result)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
return _tool_response(out)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Run code failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_drag(
|
|
page_id: str,
|
|
start_ref: str,
|
|
end_ref: str,
|
|
start_selector: str = "",
|
|
end_selector: str = "",
|
|
start_element: str = "", # pylint: disable=unused-argument
|
|
end_element: str = "", # pylint: disable=unused-argument
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
start_ref = (start_ref or "").strip()
|
|
end_ref = (end_ref or "").strip()
|
|
start_selector = (start_selector or "").strip()
|
|
end_selector = (end_selector or "").strip()
|
|
use_refs = bool(start_ref and end_ref)
|
|
use_selectors = bool(start_selector and end_selector)
|
|
if not use_refs and not use_selectors:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": ("drag needs (start_ref,end_ref) or (start_sel,end_sel)"),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
if use_refs:
|
|
start_locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
start_ref,
|
|
frame_selector,
|
|
)
|
|
end_locator = _get_locator_by_ref(
|
|
page,
|
|
page_id,
|
|
end_ref,
|
|
frame_selector,
|
|
)
|
|
if start_locator is None or end_locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "Unknown ref for drag"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
else:
|
|
start_locator = root.locator(start_selector).first
|
|
end_locator = root.locator(end_selector).first
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(start_locator.drag_to, end_locator)
|
|
else:
|
|
await start_locator.drag_to(end_locator)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Drag completed"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Drag failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_hover(
|
|
page_id: str,
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
selector: str = "",
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
ref = (ref or "").strip()
|
|
selector = (selector or "").strip()
|
|
if not ref and not selector:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "hover requires ref or selector"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if ref:
|
|
locator = _get_locator_by_ref(page, page_id, ref, frame_selector)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
else:
|
|
root = _get_root(page, page_id, frame_selector)
|
|
locator = root.locator(selector).first
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(locator.hover)
|
|
else:
|
|
await locator.hover()
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Hovered {ref or selector}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Hover failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_select_option(
|
|
page_id: str,
|
|
ref: str = "",
|
|
element: str = "", # pylint: disable=unused-argument
|
|
values_json: str = "",
|
|
frame_selector: str = "",
|
|
) -> ToolResponse:
|
|
ref = (ref or "").strip()
|
|
values = _parse_json_param(values_json, [])
|
|
if not isinstance(values, list):
|
|
values = [values] if values is not None else []
|
|
if not ref:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": "ref required for select_option"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if not values:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": "values required (JSON array or comma-separated)",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
locator = _get_locator_by_ref(page, page_id, ref, frame_selector)
|
|
if locator is None:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown ref: {ref}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(locator.select_option, value=values)
|
|
else:
|
|
await locator.select_option(value=values)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": f"Selected {values}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Select option failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_tabs( # pylint: disable=too-many-return-statements
|
|
page_id: str,
|
|
tab_action: str,
|
|
index: int,
|
|
) -> ToolResponse:
|
|
tab_action = (tab_action or "").strip().lower()
|
|
if not tab_action:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": "tab_action required (list, new, close, select)",
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
pages = _state["pages"]
|
|
page_ids = list(pages.keys())
|
|
if tab_action == "list":
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "tabs": page_ids, "count": len(page_ids)},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if tab_action == "new":
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
if not _state["_sync_context"]:
|
|
ok = await _ensure_browser()
|
|
if not ok:
|
|
err = _state.get("_last_browser_error") or "Browser not started"
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": err},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
else:
|
|
if not _state["context"]:
|
|
ok = await _ensure_browser()
|
|
if not ok:
|
|
err = _state.get("_last_browser_error") or "Browser not started"
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": err},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
page = await _run_sync(_state["_sync_context"].new_page)
|
|
else:
|
|
page = await _state["context"].new_page()
|
|
new_id = _next_page_id()
|
|
_state["refs"][new_id] = {}
|
|
_state["console_logs"][new_id] = []
|
|
_state["network_requests"][new_id] = []
|
|
_state["pending_dialogs"][new_id] = []
|
|
_attach_page_listeners(page, new_id)
|
|
_state["pages"][new_id] = page
|
|
_state["current_page_id"] = new_id
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"page_id": new_id,
|
|
"tabs": list(_state["pages"].keys()),
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"New tab failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
if tab_action == "close":
|
|
target_id = page_ids[index] if 0 <= index < len(page_ids) else page_id
|
|
return await _action_close(target_id)
|
|
if tab_action == "select":
|
|
target_id = page_ids[index] if 0 <= index < len(page_ids) else page_id
|
|
_state["current_page_id"] = target_id
|
|
return _tool_response(
|
|
json.dumps(
|
|
{
|
|
"ok": True,
|
|
"message": f"Use page_id={target_id} for later actions",
|
|
"page_id": target_id,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Unknown tab_action: {tab_action}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
|
|
|
|
async def _action_wait_for(
|
|
page_id: str,
|
|
wait_time: float,
|
|
text: str,
|
|
text_gone: str,
|
|
) -> ToolResponse:
|
|
page = _get_page(page_id)
|
|
if not page:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Page '{page_id}' not found"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
try:
|
|
if wait_time and wait_time > 0:
|
|
await asyncio.sleep(wait_time)
|
|
text = (text or "").strip()
|
|
text_gone = (text_gone or "").strip()
|
|
if text:
|
|
locator = page.get_by_text(text)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.wait_for,
|
|
state="visible",
|
|
timeout=30000,
|
|
)
|
|
else:
|
|
await locator.wait_for(
|
|
state="visible",
|
|
timeout=30000,
|
|
)
|
|
if text_gone:
|
|
locator = page.get_by_text(text_gone)
|
|
if _USE_SYNC_PLAYWRIGHT:
|
|
await _run_sync(
|
|
locator.wait_for,
|
|
state="hidden",
|
|
timeout=30000,
|
|
)
|
|
else:
|
|
await locator.wait_for(
|
|
state="hidden",
|
|
timeout=30000,
|
|
)
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": True, "message": "Wait completed"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
return _tool_response(
|
|
json.dumps(
|
|
{"ok": False, "error": f"Wait failed: {e!s}"},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|