Some checks failed
Release / semantic-release (push) Successful in 19s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m15s
Release / build-linux (push) Failing after 7m7s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
moveMouse returned ok:true even when humanHover did nothing (no on-screen box) or the selector never matched — recreating the "claims it moved but didn't" bug. Now: humanHover returns a boolean (and brings the element into view first); moveMouse returns ok:false when the target isn't found or has no on-screen box, and when site=naver/... whose box isn't on the current page it navigates to the site home first before hovering. search now reports input=human|api-fallback|api so a silent fallback to cursor-less DOM input is visible, and the tool surfaces that note in the reply instead of implying a human-like search happened.
185 lines
9.7 KiB
Python
185 lines
9.7 KiB
Python
"""Operate the on-screen (Go-Live) Chrome like a person would.
|
|
|
|
Only meaningful in screen-share mode (``STREAM_BROWSER`` true): it drives the
|
|
on-screen Chrome via the Node CDP helper (``chrome-control.mjs``) so every
|
|
action is visible on the broadcast. Pointer moves, clicks and typing are real
|
|
xdotool input (visible cursor, char-by-char typing), never synthetic DOM
|
|
events.
|
|
|
|
This is the general browser-control tool (navigate to any site, manage
|
|
windows/tabs, click, type, scroll, screenshot). ``browseAndPlay`` remains the
|
|
YouTube-playback shortcut.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
from ..base import Tool, ToolContext
|
|
from ..types import ToolExecutionResult
|
|
from ...debug import debug_log
|
|
|
|
# .../owner/src/jarvis/tools/builtin/control_browser.py -> parents[4] == .../owner
|
|
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
_NODE_SCRIPT = _REPO_ROOT / "bot" / "scripts" / "stream-test" / "chrome-control.mjs"
|
|
|
|
# Actions that don't change anything (safe, fast, no human-input latency).
|
|
_READ_ACTIONS = {"status", "listTabs"}
|
|
|
|
|
|
class ControlBrowserTool(Tool):
|
|
"""Drive the on-screen Chrome: navigate, manage tabs, click, type, scroll."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "controlBrowser"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return (
|
|
"Drive the on-screen Chrome that is shown on the broadcast, like a person would. "
|
|
"Use this (NOT webSearch) whenever the user wants something done or shown IN the "
|
|
"browser on screen: open a website or URL, search on a specific site (action "
|
|
"'search' with site=naver/google/daum/youtube/bing), go back/forward, refresh, "
|
|
"manage tabs (list/new/close/switch), close popups, move the mouse onto an element, "
|
|
"click, type, scroll, or screenshot. webSearch only returns text and shows nothing "
|
|
"on screen; this tool actually navigates the visible browser. "
|
|
"Cursor behaviour matters: 'search' and 'type' (with a selector) move the REAL mouse "
|
|
"cursor to the on-page box, click it, then type one character at a time — use these "
|
|
"when the user wants to search or type on a page. 'navigate' only types the URL into "
|
|
"the address bar (no mouse movement). 'moveMouse' moves/hovers the visible cursor "
|
|
"onto an element (selector, or site=naver/... for that site's search box) WITHOUT "
|
|
"clicking — use it when the user just asks to move the mouse somewhere. "
|
|
"Only available in screen-share mode. "
|
|
"Never claim you did any of this unless this tool returns success."
|
|
)
|
|
|
|
@property
|
|
def inputSchema(self) -> Dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": [
|
|
"status", "listTabs", "navigate", "search", "back",
|
|
"forward", "refresh", "newTab", "closeTab", "activateTab",
|
|
"closePopups", "moveMouse", "click", "type", "scroll", "pressKey",
|
|
"screenshot",
|
|
],
|
|
"description": "What to do in the browser.",
|
|
},
|
|
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
|
|
"query": {"type": "string", "description": "Search text for action 'search'."},
|
|
"site": {"type": "string", "description": "Site for action 'search' or 'moveMouse': naver, google, daum, youtube, bing. For moveMouse it targets that site's search box."},
|
|
"index": {"type": "integer", "description": "Tab index for closeTab/activateTab (from listTabs)."},
|
|
"selector": {"type": "string", "description": "CSS selector for click/type/moveMouse."},
|
|
"text": {"type": "string", "description": "Text to type."},
|
|
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
|
|
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
|
|
},
|
|
"required": ["action"],
|
|
}
|
|
|
|
def run(self, args: Optional[Dict[str, Any]], context: ToolContext) -> ToolExecutionResult:
|
|
cfg = context.cfg
|
|
if not getattr(cfg, "stream_browser", True):
|
|
return ToolExecutionResult(
|
|
success=False,
|
|
reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 브라우저를 제어할 수 있습니다.",
|
|
)
|
|
if not _NODE_SCRIPT.exists():
|
|
return ToolExecutionResult(success=False, reply_text="브라우저 제어 도구를 찾을 수 없습니다.")
|
|
if not args or not isinstance(args, dict):
|
|
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
|
|
|
|
action = str(args.get("action", "")).strip()
|
|
if not action:
|
|
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
|
|
|
|
# Pass the whole arg dict through as the command (the Node side reads the
|
|
# fields it needs per action).
|
|
command = json.dumps(args, ensure_ascii=False)
|
|
context.user_print(f"🖱️ 브라우저 제어: {action}")
|
|
debug_log(f" 🖱️ controlBrowser {command[:120]}", "tools")
|
|
# Human-input actions need time for the visible cursor move + char typing.
|
|
timeout = 25 if action in _READ_ACTIONS else 90
|
|
# Split deployment: when the browser (Chrome + X + xdotool) lives on a
|
|
# different machine, send the command to its control-server so the REAL
|
|
# input lands on that host's screen. Otherwise run chrome-control.mjs
|
|
# locally (all-in-one / browser-host layout).
|
|
control_url = os.environ.get("BROWSER_CONTROL_URL", "").strip()
|
|
try:
|
|
if control_url:
|
|
import urllib.request
|
|
req = urllib.request.Request(
|
|
control_url.rstrip("/") + "/control",
|
|
data=command.encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
data = json.loads((resp.read().decode("utf-8") or "").strip() or "{}")
|
|
else:
|
|
proc = subprocess.run(
|
|
["node", str(_NODE_SCRIPT), command],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env={**os.environ, "CDP_PORT": os.environ.get("CDP_PORT", "9222")},
|
|
)
|
|
data = json.loads((proc.stdout or "").strip() or "{}")
|
|
except Exception as e:
|
|
return ToolExecutionResult(success=False, reply_text=f"브라우저 제어에 실패했습니다: {e}")
|
|
|
|
if not data.get("ok"):
|
|
return ToolExecutionResult(
|
|
success=False, reply_text=f"브라우저 제어에 실패했습니다: {data.get('error', 'unknown')}"
|
|
)
|
|
|
|
# Return a factual summary of what ACTUALLY happened so the reply engine
|
|
# describes the real outcome and doesn't confabulate.
|
|
summary = self._summarise(action, args, data)
|
|
if action == "status":
|
|
# The broadcast (Go-Live) state lives in the bot runtime, surfaced
|
|
# to the tool via the turn context — report it alongside the tabs.
|
|
bc = getattr(context, "broadcasting", None)
|
|
state = "켜짐" if bc else ("꺼짐" if bc is not None else "알 수 없음")
|
|
summary = f"📡 방송: {state}\n{summary}"
|
|
return ToolExecutionResult(success=True, reply_text=summary)
|
|
|
|
@staticmethod
|
|
def _summarise(action: str, args: Dict[str, Any], data: Dict[str, Any]) -> str:
|
|
if action == "navigate":
|
|
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
|
|
if action == "search":
|
|
base = f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
|
# Flag when the real cursor path didn't run, so a silent fallback to
|
|
# cursor-less DOM input is visible rather than reported as "human".
|
|
if data.get("input") in ("api", "api-fallback"):
|
|
base += " (참고: 실제 마우스 커서 이동 없이 처리됨)"
|
|
return base
|
|
if action in ("back", "forward", "refresh"):
|
|
return f"브라우저: {action} 완료 ({data.get('url', '')})."
|
|
if action in ("status", "listTabs"):
|
|
tabs = data.get("tabs", [])
|
|
lines = "\n".join(f" [{t['index']}] {t.get('title') or t['url']}" for t in tabs) or " (탭 없음)"
|
|
return f"브라우저 탭 {len(tabs)}개:\n{lines}"
|
|
if action == "newTab":
|
|
return f"새 탭을 열었습니다 (index {data.get('index')})."
|
|
if action == "closeTab":
|
|
return f"탭 {data.get('closed')}번을 닫았습니다 (남은 탭 {data.get('remaining')}개)."
|
|
if action == "activateTab":
|
|
return f"탭 {data.get('active')}번으로 전환했습니다."
|
|
if action == "closePopups":
|
|
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다."
|
|
if action == "moveMouse":
|
|
target = data.get("target") or args.get("selector") or args.get("site") or "대상"
|
|
return f"마우스 커서를 {target} 위치로 옮겼습니다."
|
|
if action == "screenshot":
|
|
return f"화면을 캡처했습니다: {data.get('path')}"
|
|
return "완료했습니다."
|