mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
browser: replace browser-use agent with native browser
Introduce the new built-in Browser plugin for Agent Zero, replacing the legacy browser-use-based browser agent with a direct Playwright-powered browser tool, live WebUI viewer, browser session controls, status APIs, configuration, and extension-management support. Add browser-specific modal behavior so the browser can run as a floating, resizable, no-backdrop window, including modal focus, toggle, and idempotent open helpers for richer WebUI surfaces. Remove the old `_browser_agent` core plugin and the `browser-use` dependency, then clean up stale browser-model wiring and references across agent code, model configuration docs, setup guides, troubleshooting docs, skills, and Agent Zero knowledge. Update regression and WebUI extension-surface coverage for the new browser architecture and modal behavior. The legacy browser-use implementation has been extracted from core so it can continue separately as a community plugin published through the A0 Plugin Index for any user or professional that were relying on it for workflow.
This commit is contained in:
parent
603fc2064b
commit
983d431a5e
65 changed files with 6936 additions and 1926 deletions
27
plugins/_browser/api/extensions.py
Normal file
27
plugins/_browser/api/extensions.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from helpers.api import ApiHandler, Request
|
||||
from plugins._browser.helpers.extension_manager import (
|
||||
get_extensions_root,
|
||||
install_chrome_web_store_extension,
|
||||
list_browser_extensions,
|
||||
)
|
||||
|
||||
|
||||
class Extensions(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
action = input.get("action", "list")
|
||||
|
||||
if action == "list":
|
||||
return {
|
||||
"ok": True,
|
||||
"root": str(get_extensions_root()),
|
||||
"extensions": list_browser_extensions(),
|
||||
}
|
||||
|
||||
if action == "install_web_store":
|
||||
try:
|
||||
result = install_chrome_web_store_extension(str(input.get("url", "")))
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return result
|
||||
|
||||
return {"ok": False, "error": f"Unknown action: {action}"}
|
||||
32
plugins/_browser/api/status.py
Normal file
32
plugins/_browser/api/status.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from helpers.api import ApiHandler, Request
|
||||
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
|
||||
from plugins._browser.helpers.playwright import get_playwright_binary, get_playwright_cache_dir
|
||||
from plugins._browser.helpers.runtime import known_context_ids
|
||||
|
||||
|
||||
class Status(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
browser_config = get_browser_config()
|
||||
launch_config = build_browser_launch_config(browser_config)
|
||||
runtime_binary = get_playwright_binary(
|
||||
full_browser=launch_config["requires_full_browser"]
|
||||
)
|
||||
shell_binary = get_playwright_binary(full_browser=False)
|
||||
chromium_binary = get_playwright_binary(full_browser=True)
|
||||
return {
|
||||
"plugin": "_browser",
|
||||
"playwright": {
|
||||
"cache_dir": get_playwright_cache_dir(),
|
||||
"binary_found": bool(runtime_binary),
|
||||
"binary_path": str(runtime_binary) if runtime_binary else "",
|
||||
"headless_shell_binary_path": str(shell_binary) if shell_binary else "",
|
||||
"chromium_binary_path": str(chromium_binary) if chromium_binary else "",
|
||||
"launch_mode": launch_config["browser_mode"],
|
||||
},
|
||||
"extensions": {
|
||||
**launch_config["extensions"],
|
||||
"launch_mode": launch_config["browser_mode"],
|
||||
"requires_full_browser": launch_config["requires_full_browser"],
|
||||
},
|
||||
"contexts": known_context_ids(),
|
||||
}
|
||||
241
plugins/_browser/api/ws_browser.py
Normal file
241
plugins/_browser/api/ws_browser.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent import AgentContext
|
||||
from helpers.ws import WsHandler
|
||||
from helpers.ws_manager import WsResult
|
||||
from plugins._browser.helpers.runtime import get_runtime
|
||||
|
||||
|
||||
class WsBrowser(WsHandler):
|
||||
_streams: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
|
||||
|
||||
async def on_disconnect(self, sid: str) -> None:
|
||||
for key in [key for key in self._streams if key[0] == sid]:
|
||||
task = self._streams.pop(key)
|
||||
task.cancel()
|
||||
|
||||
async def process(
|
||||
self,
|
||||
event: str,
|
||||
data: dict[str, Any],
|
||||
sid: str,
|
||||
) -> dict[str, Any] | WsResult | None:
|
||||
if not event.startswith("browser_"):
|
||||
return None
|
||||
|
||||
if event == "browser_viewer_subscribe":
|
||||
return await self._subscribe(data, sid)
|
||||
if event == "browser_viewer_unsubscribe":
|
||||
return self._unsubscribe(data, sid)
|
||||
if event == "browser_viewer_command":
|
||||
return await self._command(data, sid)
|
||||
if event == "browser_viewer_input":
|
||||
return await self._input(data, sid)
|
||||
|
||||
return WsResult.error(
|
||||
code="UNKNOWN_BROWSER_EVENT",
|
||||
message=f"Unknown browser event: {event}",
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
|
||||
async def _subscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
||||
context_id = self._context_id(data)
|
||||
if not context_id:
|
||||
return self._error("MISSING_CONTEXT", "context_id is required", data)
|
||||
if not AgentContext.get(context_id):
|
||||
return self._error("CONTEXT_NOT_FOUND", f"Context '{context_id}' was not found", data)
|
||||
|
||||
runtime = await get_runtime(context_id)
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if not browsers:
|
||||
opened = await runtime.call("open", "about:blank")
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if opened.get("id"):
|
||||
listing["last_interacted_browser_id"] = opened.get("id")
|
||||
active_id = data.get("browser_id") or listing.get("last_interacted_browser_id")
|
||||
if not active_id and browsers:
|
||||
active_id = browsers[0].get("id")
|
||||
|
||||
stream_key = (sid, context_id)
|
||||
existing = self._streams.pop(stream_key, None)
|
||||
if existing:
|
||||
existing.cancel()
|
||||
self._streams[stream_key] = asyncio.create_task(
|
||||
self._stream_frames(sid, context_id, active_id)
|
||||
)
|
||||
|
||||
return {
|
||||
"context_id": context_id,
|
||||
"active_browser_id": active_id,
|
||||
"browsers": browsers,
|
||||
}
|
||||
|
||||
def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
||||
context_id = self._context_id(data)
|
||||
if not context_id:
|
||||
return self._error("MISSING_CONTEXT", "context_id is required", data)
|
||||
task = self._streams.pop((sid, context_id), None)
|
||||
if task:
|
||||
task.cancel()
|
||||
return {"context_id": context_id, "unsubscribed": True}
|
||||
|
||||
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
||||
context_id = self._context_id(data)
|
||||
if not context_id:
|
||||
return self._error("MISSING_CONTEXT", "context_id is required", data)
|
||||
runtime = await get_runtime(context_id)
|
||||
command = str(data.get("command") or "").strip().lower().replace("-", "_")
|
||||
browser_id = data.get("browser_id")
|
||||
|
||||
try:
|
||||
if command == "open":
|
||||
result = await runtime.call("open", data.get("url") or "about:blank")
|
||||
elif command == "navigate":
|
||||
result = await runtime.call("navigate", browser_id, data.get("url") or "")
|
||||
elif command == "back":
|
||||
result = await runtime.call("back", browser_id)
|
||||
elif command == "forward":
|
||||
result = await runtime.call("forward", browser_id)
|
||||
elif command == "reload":
|
||||
result = await runtime.call("reload", browser_id)
|
||||
elif command == "close":
|
||||
result = await runtime.call("close_browser", browser_id)
|
||||
elif command == "list":
|
||||
result = await runtime.call("list")
|
||||
else:
|
||||
return self._error("UNKNOWN_COMMAND", f"Unknown browser command: {command}", data)
|
||||
except Exception as exc:
|
||||
return self._error("COMMAND_FAILED", str(exc), data)
|
||||
|
||||
listing = await runtime.call("list")
|
||||
last_interacted_browser_id = listing.get("last_interacted_browser_id")
|
||||
await self.emit_to(
|
||||
sid,
|
||||
"browser_viewer_state",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"result": result,
|
||||
"browsers": listing.get("browsers") or [],
|
||||
"last_interacted_browser_id": last_interacted_browser_id,
|
||||
},
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
return {
|
||||
"result": result,
|
||||
"browsers": listing.get("browsers") or [],
|
||||
"last_interacted_browser_id": last_interacted_browser_id,
|
||||
}
|
||||
|
||||
async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
||||
context_id = self._context_id(data)
|
||||
if not context_id:
|
||||
return self._error("MISSING_CONTEXT", "context_id is required", data)
|
||||
runtime = await get_runtime(context_id, create=False)
|
||||
if not runtime:
|
||||
return self._error("NO_BROWSER_RUNTIME", "No browser runtime exists for this context", data)
|
||||
|
||||
input_type = str(data.get("input_type") or "").strip().lower()
|
||||
browser_id = data.get("browser_id")
|
||||
try:
|
||||
if input_type == "mouse":
|
||||
result = await runtime.call(
|
||||
"mouse",
|
||||
browser_id,
|
||||
data.get("event_type") or "click",
|
||||
float(data.get("x") or 0),
|
||||
float(data.get("y") or 0),
|
||||
data.get("button") or "left",
|
||||
)
|
||||
elif input_type == "keyboard":
|
||||
result = await runtime.call(
|
||||
"keyboard",
|
||||
browser_id,
|
||||
key=str(data.get("key") or ""),
|
||||
text=str(data.get("text") or ""),
|
||||
)
|
||||
elif input_type == "viewport":
|
||||
result = await runtime.call(
|
||||
"set_viewport",
|
||||
browser_id,
|
||||
int(data.get("width") or 0),
|
||||
int(data.get("height") or 0),
|
||||
)
|
||||
elif input_type == "wheel":
|
||||
result = await runtime.call(
|
||||
"wheel",
|
||||
browser_id,
|
||||
float(data.get("x") or 0),
|
||||
float(data.get("y") or 0),
|
||||
float(data.get("delta_x") or 0),
|
||||
float(data.get("delta_y") or 0),
|
||||
)
|
||||
else:
|
||||
return self._error("UNKNOWN_INPUT", f"Unknown browser input: {input_type}", data)
|
||||
except Exception as exc:
|
||||
return self._error("INPUT_FAILED", str(exc), data)
|
||||
|
||||
return {"state": result}
|
||||
|
||||
async def _stream_frames(
|
||||
self,
|
||||
sid: str,
|
||||
context_id: str,
|
||||
browser_id: int | str | None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
runtime = await get_runtime(context_id, create=False)
|
||||
if runtime:
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
browser_ids = {str(browser.get("id")) for browser in browsers}
|
||||
requested_id = str(browser_id or "") if browser_id else ""
|
||||
active_id = (
|
||||
browser_id
|
||||
if requested_id and requested_id in browser_ids
|
||||
else listing.get("last_interacted_browser_id")
|
||||
)
|
||||
if active_id and str(active_id) not in browser_ids:
|
||||
active_id = None
|
||||
if not active_id and browsers:
|
||||
active_id = browsers[0].get("id")
|
||||
if active_id:
|
||||
frame = await runtime.call("screenshot", active_id)
|
||||
frame["context_id"] = context_id
|
||||
frame["browsers"] = browsers
|
||||
await self.emit_to(sid, "browser_viewer_frame", frame)
|
||||
else:
|
||||
await self.emit_to(
|
||||
sid,
|
||||
"browser_viewer_frame",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"browser_id": None,
|
||||
"browsers": browsers,
|
||||
"image": "",
|
||||
"mime": "",
|
||||
"state": None,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.75)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
@staticmethod
|
||||
def _context_id(data: dict[str, Any]) -> str:
|
||||
return str(data.get("context_id") or data.get("context") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _error(code: str, message: str, data: dict[str, Any]) -> WsResult:
|
||||
return WsResult.error(
|
||||
code=code,
|
||||
message=message,
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
2891
plugins/_browser/assets/browser-page-content.js
Normal file
2891
plugins/_browser/assets/browser-page-content.js
Normal file
File diff suppressed because it is too large
Load diff
10
plugins/_browser/default_config.yaml
Normal file
10
plugins/_browser/default_config.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Load unpacked Chromium extension directories into the Browser tool.
|
||||
# Paths must be readable from the Agent Zero runtime itself.
|
||||
extensions_enabled: false
|
||||
|
||||
# One unpacked extension directory per item.
|
||||
extension_paths: []
|
||||
|
||||
# Optional _model_config preset used by Browser-owned model helpers.
|
||||
# Empty uses the effective Main Model.
|
||||
model_preset: ""
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
from helpers.extension import Extension
|
||||
from plugins._browser.helpers.runtime import close_runtime_sync
|
||||
|
||||
|
||||
class CleanupBrowserRuntimeOnRemove(Extension):
|
||||
def execute(self, data: dict = {}, **kwargs):
|
||||
args = data.get("args", ())
|
||||
context_id = args[0] if isinstance(args, tuple) and args else ""
|
||||
if context_id:
|
||||
close_runtime_sync(str(context_id), delete_profile=True)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
from helpers.extension import Extension
|
||||
from plugins._browser.helpers.runtime import close_runtime_sync
|
||||
|
||||
|
||||
class CleanupBrowserRuntimeOnReset(Extension):
|
||||
def execute(self, data: dict = {}, **kwargs):
|
||||
args = data.get("args", ())
|
||||
context = args[0] if isinstance(args, tuple) and args else None
|
||||
context_id = getattr(context, "id", "")
|
||||
if context_id:
|
||||
close_runtime_sync(context_id, delete_profile=True)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent import LoopData
|
||||
from helpers.extension import Extension
|
||||
from plugins._browser.helpers.runtime import get_runtime
|
||||
|
||||
|
||||
class BrowserContextPrompt(Extension):
|
||||
async def execute(
|
||||
self,
|
||||
system_prompt: list[str] = [],
|
||||
loop_data: LoopData = LoopData(),
|
||||
**kwargs: Any,
|
||||
):
|
||||
if not self.agent:
|
||||
return
|
||||
|
||||
runtime = await get_runtime(self.agent.context.id, create=False)
|
||||
if not runtime:
|
||||
return
|
||||
|
||||
try:
|
||||
listing = await runtime.call("list")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
browsers = listing.get("browsers") or []
|
||||
if not browsers:
|
||||
return
|
||||
|
||||
rows = ["browser id|url|title"]
|
||||
for browser in browsers:
|
||||
rows.append(
|
||||
f"{browser.get('id')}|{browser.get('currentUrl', '')}|{browser.get('title', '')}"
|
||||
)
|
||||
|
||||
section = ["currently open web browsers", "\n".join(rows)]
|
||||
last_id = listing.get("last_interacted_browser_id")
|
||||
if last_id:
|
||||
try:
|
||||
state = await runtime.call("state", last_id)
|
||||
content = await runtime.call("content", last_id, None)
|
||||
document = content.get("document") if isinstance(content, dict) else ""
|
||||
if document:
|
||||
section.extend(
|
||||
[
|
||||
"",
|
||||
"last interacted web browser",
|
||||
f"browser id|url|title\n{state.get('id')}|{state.get('currentUrl', '')}|{state.get('title', '')}",
|
||||
"page content↓",
|
||||
str(document),
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
system_prompt.append("\n".join(section))
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._browser.api.ws_browser import WsBrowser
|
||||
|
||||
|
||||
class BrowserWebuiWsDisconnect(Extension):
|
||||
async def execute(
|
||||
self,
|
||||
instance: Any = None,
|
||||
sid: str = "",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if instance is None:
|
||||
return
|
||||
handler = WsBrowser(
|
||||
instance.socketio,
|
||||
instance.lock,
|
||||
manager=instance.manager,
|
||||
namespace=instance.namespace,
|
||||
)
|
||||
await handler.on_disconnect(sid)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from helpers.ws_manager import WsResult
|
||||
from plugins._browser.api.ws_browser import WsBrowser
|
||||
|
||||
|
||||
class BrowserWebuiWsEvents(Extension):
|
||||
async def execute(
|
||||
self,
|
||||
instance: Any = None,
|
||||
sid: str = "",
|
||||
event_type: str = "",
|
||||
data: dict[str, Any] | None = None,
|
||||
response_data: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if not event_type.startswith("browser_") or instance is None or response_data is None:
|
||||
return
|
||||
|
||||
handler = WsBrowser(
|
||||
instance.socketio,
|
||||
instance.lock,
|
||||
manager=instance.manager,
|
||||
namespace=instance.namespace,
|
||||
)
|
||||
result = await handler.process(event_type, data or {}, sid)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
if isinstance(result, WsResult):
|
||||
payload = result.as_result(
|
||||
handler_id=handler.identifier,
|
||||
fallback_correlation_id=(data or {}).get("correlationId"),
|
||||
)
|
||||
if payload.get("ok"):
|
||||
response_data.update(payload.get("data") or {})
|
||||
else:
|
||||
response_data["browser_error"] = payload.get("error") or {
|
||||
"code": "BROWSER_ERROR",
|
||||
"error": "Browser request failed",
|
||||
}
|
||||
return
|
||||
|
||||
response_data.update(result)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<button
|
||||
type="button"
|
||||
class="text-button browser-chat-action"
|
||||
title="Show or hide Browser"
|
||||
aria-label="Show or hide Browser"
|
||||
data-bs-placement="top"
|
||||
data-bs-trigger="hover"
|
||||
@click="window.toggleModal ? window.toggleModal('/plugins/_browser/webui/main.html') : window.openModal('/plugins/_browser/webui/main.html')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2"></rect>
|
||||
<path d="M3 8h18"></path>
|
||||
<path d="M7 6h.01"></path>
|
||||
<path d="M10 6h.01"></path>
|
||||
</svg>
|
||||
<p>Browser</p>
|
||||
</button>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
createActionButton,
|
||||
copyToClipboard,
|
||||
} from "/components/messages/action-buttons/simple-action-buttons.js";
|
||||
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
|
||||
import { store as speechStore } from "/components/chat/speech/speech-store.js";
|
||||
import {
|
||||
buildDetailPayload,
|
||||
cleanStepTitle,
|
||||
drawProcessStep,
|
||||
} from "/js/messages.js";
|
||||
|
||||
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
|
||||
|
||||
export default async function registerBrowserToolHandler(extData) {
|
||||
if (extData?.tool_name === "browser") {
|
||||
extData.handler = drawBrowserTool;
|
||||
}
|
||||
}
|
||||
|
||||
function drawBrowserTool({
|
||||
id,
|
||||
type,
|
||||
heading,
|
||||
content,
|
||||
kvps,
|
||||
timestamp,
|
||||
agentno = 0,
|
||||
...additional
|
||||
}) {
|
||||
const title = cleanStepTitle(heading);
|
||||
const displayKvps = { ...kvps };
|
||||
const headerLabels = [
|
||||
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
|
||||
].filter(Boolean);
|
||||
const contentText = String(content ?? "");
|
||||
const browserButton = createActionButton(
|
||||
"visibility",
|
||||
"Browser",
|
||||
() => {
|
||||
if (window.ensureModalOpen) {
|
||||
void window.ensureModalOpen(BROWSER_MODAL);
|
||||
return;
|
||||
}
|
||||
void window.openModal?.(BROWSER_MODAL);
|
||||
},
|
||||
);
|
||||
browserButton.setAttribute("title", "Open Browser");
|
||||
browserButton.setAttribute("aria-label", "Open Browser");
|
||||
browserButton.setAttribute("data-bs-placement", "top");
|
||||
browserButton.setAttribute("data-bs-trigger", "hover");
|
||||
const actionButtons = [browserButton];
|
||||
|
||||
if (contentText.trim()) {
|
||||
actionButtons.push(
|
||||
createActionButton("detail", "", () =>
|
||||
stepDetailStore.showStepDetail(
|
||||
buildDetailPayload(arguments[0], { headerLabels }),
|
||||
),
|
||||
),
|
||||
createActionButton("speak", "", () => speechStore.speak(contentText)),
|
||||
createActionButton("copy", "", () => copyToClipboard(contentText)),
|
||||
);
|
||||
}
|
||||
|
||||
return drawProcessStep({
|
||||
id,
|
||||
title,
|
||||
code: "WWW",
|
||||
classes: undefined,
|
||||
kvps: displayKvps,
|
||||
content,
|
||||
actionButtons: actionButtons.filter(Boolean),
|
||||
log: arguments[0],
|
||||
});
|
||||
}
|
||||
1
plugins/_browser/helpers/__init__.py
Normal file
1
plugins/_browser/helpers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Built-in direct browser helpers.
|
||||
272
plugins/_browser/helpers/config.py
Normal file
272
plugins/_browser/helpers/config.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent import Agent
|
||||
|
||||
|
||||
PLUGIN_NAME = "_browser"
|
||||
MODEL_PRESET_KEY = "model_preset"
|
||||
BASE_BROWSER_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_extension_paths(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
candidates = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
candidates = list(value)
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
normalized_paths: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for entry in candidates:
|
||||
raw_path = str(entry or "").strip()
|
||||
if not raw_path:
|
||||
continue
|
||||
normalized = str(Path(raw_path).expanduser())
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
normalized_paths.append(normalized)
|
||||
return normalized_paths
|
||||
|
||||
|
||||
def _normalize_model_preset(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = settings if isinstance(settings, dict) else {}
|
||||
return {
|
||||
"extensions_enabled": bool(raw.get("extensions_enabled", False)),
|
||||
"extension_paths": _normalize_extension_paths(raw.get("extension_paths", [])),
|
||||
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
|
||||
}
|
||||
|
||||
|
||||
def browser_runtime_config(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
config = normalize_browser_config(settings)
|
||||
return {
|
||||
"extensions_enabled": config["extensions_enabled"],
|
||||
"extension_paths": config["extension_paths"],
|
||||
}
|
||||
|
||||
|
||||
def get_browser_config(agent: "Agent | None" = None) -> dict[str, Any]:
|
||||
from helpers import plugins
|
||||
|
||||
return normalize_browser_config(plugins.get_plugin_config(PLUGIN_NAME, agent=agent) or {})
|
||||
|
||||
|
||||
def get_browser_model_preset_name(
|
||||
agent: "Agent | None" = None,
|
||||
settings: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
config = (
|
||||
normalize_browser_config(settings)
|
||||
if settings is not None
|
||||
else get_browser_config(agent=agent)
|
||||
)
|
||||
return str(config.get(MODEL_PRESET_KEY, "") or "").strip()
|
||||
|
||||
|
||||
def get_browser_model_preset_options(
|
||||
agent: "Agent | None" = None,
|
||||
settings: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
||||
selected_name = get_browser_model_preset_name(agent=agent, settings=settings)
|
||||
options: list[dict[str, Any]] = []
|
||||
found_selected = False
|
||||
|
||||
for preset in model_config.get_presets():
|
||||
name = str(preset.get("name", "") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if name == selected_name:
|
||||
found_selected = True
|
||||
chat_cfg = preset.get("chat", {}) if isinstance(preset, dict) else {}
|
||||
if not isinstance(chat_cfg, dict):
|
||||
chat_cfg = {}
|
||||
provider = str(chat_cfg.get("provider", "") or "").strip()
|
||||
model_name = str(chat_cfg.get("name", "") or "").strip()
|
||||
summary = " / ".join(part for part in (provider, model_name) if part)
|
||||
options.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": name,
|
||||
"missing": False,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
if selected_name and not found_selected:
|
||||
options.append(
|
||||
{
|
||||
"name": selected_name,
|
||||
"label": f"{selected_name} (missing)",
|
||||
"missing": True,
|
||||
"summary": "",
|
||||
}
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def resolve_browser_model_selection(
|
||||
agent: "Agent | None" = None,
|
||||
settings: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
||||
preset_name = get_browser_model_preset_name(agent=agent, settings=settings)
|
||||
if preset_name:
|
||||
preset = model_config.get_preset_by_name(preset_name)
|
||||
if isinstance(preset, dict):
|
||||
chat_cfg = preset.get("chat", {})
|
||||
if isinstance(chat_cfg, dict) and (
|
||||
str(chat_cfg.get("provider", "") or "").strip()
|
||||
or str(chat_cfg.get("name", "") or "").strip()
|
||||
):
|
||||
return {
|
||||
"config": chat_cfg,
|
||||
"source_kind": "preset",
|
||||
"source_label": f"Preset '{preset_name}' via _model_config",
|
||||
"selected_preset_name": preset_name,
|
||||
"preset_status": "active",
|
||||
"warning": "",
|
||||
}
|
||||
return {
|
||||
"config": model_config.get_chat_model_config(agent),
|
||||
"source_kind": "main",
|
||||
"source_label": "Main Model via _model_config",
|
||||
"selected_preset_name": preset_name,
|
||||
"preset_status": "invalid",
|
||||
"warning": (
|
||||
f"Configured browser preset '{preset_name}' does not define a chat model. "
|
||||
"Falling back to the Main Model."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"config": model_config.get_chat_model_config(agent),
|
||||
"source_kind": "main",
|
||||
"source_label": "Main Model via _model_config",
|
||||
"selected_preset_name": preset_name,
|
||||
"preset_status": "missing",
|
||||
"warning": (
|
||||
f"Configured browser preset '{preset_name}' was not found. "
|
||||
"Falling back to the Main Model."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"config": model_config.get_chat_model_config(agent),
|
||||
"source_kind": "main",
|
||||
"source_label": "Main Model via _model_config",
|
||||
"selected_preset_name": "",
|
||||
"preset_status": "none",
|
||||
"warning": "",
|
||||
}
|
||||
|
||||
|
||||
def resolve_browser_model(agent: "Agent", settings: dict[str, Any] | None = None):
|
||||
selection = resolve_browser_model_selection(agent=agent, settings=settings)
|
||||
if selection["source_kind"] == "main":
|
||||
return agent.get_chat_model()
|
||||
|
||||
import models
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
||||
model_config_object = model_config.build_model_config(
|
||||
selection["config"],
|
||||
models.ModelType.CHAT,
|
||||
)
|
||||
return models.get_chat_model(
|
||||
model_config_object.provider,
|
||||
model_config_object.name,
|
||||
model_config=model_config_object,
|
||||
**model_config_object.build_kwargs(),
|
||||
)
|
||||
|
||||
|
||||
def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
config = normalize_browser_config(settings)
|
||||
path_details: list[dict[str, Any]] = []
|
||||
for extension_path in config["extension_paths"]:
|
||||
path = Path(extension_path)
|
||||
exists = path.exists()
|
||||
is_dir = path.is_dir() if exists else False
|
||||
path_details.append(
|
||||
{
|
||||
"path": extension_path,
|
||||
"exists": exists,
|
||||
"is_dir": is_dir,
|
||||
"loadable": exists and is_dir,
|
||||
}
|
||||
)
|
||||
|
||||
active_paths = [item["path"] for item in path_details if item["loadable"]]
|
||||
invalid_paths = [item["path"] for item in path_details if not item["loadable"]]
|
||||
active = bool(config["extensions_enabled"] and active_paths)
|
||||
|
||||
warnings: list[str] = []
|
||||
if config["extensions_enabled"] and not config["extension_paths"]:
|
||||
warnings.append(
|
||||
"Extensions are enabled, but no unpacked extension directories are configured."
|
||||
)
|
||||
elif config["extensions_enabled"] and not active_paths:
|
||||
warnings.append(
|
||||
"Extensions are enabled, but none of the configured extension directories are readable unpacked folders."
|
||||
)
|
||||
elif invalid_paths:
|
||||
warnings.append(
|
||||
"Some configured extension directories are missing or not directories, so they will be skipped."
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": bool(config["extensions_enabled"]),
|
||||
"active": active,
|
||||
"configured_paths": config["extension_paths"],
|
||||
"active_paths": active_paths,
|
||||
"invalid_paths": invalid_paths,
|
||||
"path_details": path_details,
|
||||
"active_path_count": len(active_paths),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
extensions = describe_browser_extensions(settings)
|
||||
args = list(BASE_BROWSER_ARGS)
|
||||
channel: str | None = None
|
||||
browser_mode = "headless_shell"
|
||||
|
||||
if extensions["active"]:
|
||||
joined_paths = ",".join(extensions["active_paths"])
|
||||
args.extend(
|
||||
[
|
||||
f"--disable-extensions-except={joined_paths}",
|
||||
f"--load-extension={joined_paths}",
|
||||
]
|
||||
)
|
||||
channel = "chromium"
|
||||
browser_mode = "chromium_extensions"
|
||||
else:
|
||||
args.insert(0, "--headless=new")
|
||||
|
||||
return {
|
||||
"args": args,
|
||||
"browser_mode": browser_mode,
|
||||
"channel": channel,
|
||||
"extensions": extensions,
|
||||
"requires_full_browser": bool(extensions["active"]),
|
||||
}
|
||||
177
plugins/_browser/helpers/extension_manager.py
Normal file
177
plugins/_browser/helpers/extension_manager.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import files, plugins
|
||||
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
|
||||
|
||||
|
||||
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
|
||||
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
|
||||
WEB_STORE_DOWNLOAD_URL = (
|
||||
"https://clients2.google.com/service/update2/crx"
|
||||
"?response=redirect"
|
||||
"&prodversion=120.0.0.0"
|
||||
"&acceptformat=crx2,crx3"
|
||||
"&x=id%3D{extension_id}%26installsource%3Dondemand%26uc"
|
||||
)
|
||||
|
||||
|
||||
def get_extensions_root() -> Path:
|
||||
root = Path(files.get_abs_path("usr/browser-extensions"))
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def parse_chrome_web_store_extension_id(value: str) -> str:
|
||||
source = str(value or "").strip()
|
||||
if EXTENSION_ID_RE.fullmatch(source):
|
||||
return source
|
||||
|
||||
match = WEB_STORE_ID_RE.search(source)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
raise ValueError("Enter a Chrome Web Store URL or a 32-character extension id.")
|
||||
|
||||
|
||||
def list_browser_extensions() -> list[dict[str, Any]]:
|
||||
root = get_extensions_root()
|
||||
config = get_browser_config()
|
||||
enabled_paths = {str(Path(path).expanduser()) for path in config["extension_paths"]}
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
for manifest_path in sorted(root.glob("**/manifest.json")):
|
||||
extension_dir = manifest_path.parent
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
manifest = {}
|
||||
extension_path = str(extension_dir)
|
||||
entries.append(
|
||||
{
|
||||
"name": manifest.get("name") or extension_dir.name,
|
||||
"version": manifest.get("version") or "",
|
||||
"path": extension_path,
|
||||
"enabled": extension_path in enabled_paths,
|
||||
}
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
|
||||
extension_id = parse_chrome_web_store_extension_id(source)
|
||||
target = get_extensions_root() / "chrome-web-store" / extension_id
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="a0-browser-ext-") as tmp:
|
||||
archive_path = Path(tmp) / f"{extension_id}.crx"
|
||||
_download_crx(extension_id, archive_path)
|
||||
payload_path = Path(tmp) / f"{extension_id}.zip"
|
||||
payload_path.write_bytes(_crx_zip_payload(archive_path.read_bytes()))
|
||||
extracted_path = Path(tmp) / "extracted"
|
||||
_safe_extract_zip(payload_path, extracted_path)
|
||||
|
||||
if not (extracted_path / "manifest.json").is_file():
|
||||
raise ValueError("Downloaded extension did not contain a manifest.json file.")
|
||||
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(extracted_path, target)
|
||||
|
||||
config = _enable_extension_path(target)
|
||||
manifest = _read_manifest(target)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": extension_id,
|
||||
"name": manifest.get("name") or extension_id,
|
||||
"version": manifest.get("version") or "",
|
||||
"path": str(target),
|
||||
"extensions_enabled": config["extensions_enabled"],
|
||||
"extension_paths": config["extension_paths"],
|
||||
}
|
||||
|
||||
|
||||
def _download_crx(extension_id: str, archive_path: Path) -> None:
|
||||
url = WEB_STORE_DOWNLOAD_URL.format(extension_id=extension_id)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
data = response.read()
|
||||
if not data:
|
||||
raise ValueError("Chrome Web Store returned an empty extension package.")
|
||||
archive_path.write_bytes(data)
|
||||
|
||||
|
||||
def _crx_zip_payload(data: bytes) -> bytes:
|
||||
if data.startswith(b"PK"):
|
||||
return data
|
||||
if data[:4] != b"Cr24":
|
||||
raise ValueError("Downloaded package is not a CRX or ZIP archive.")
|
||||
|
||||
version = int.from_bytes(data[4:8], "little")
|
||||
if version == 2:
|
||||
public_key_len = int.from_bytes(data[8:12], "little")
|
||||
signature_len = int.from_bytes(data[12:16], "little")
|
||||
offset = 16 + public_key_len + signature_len
|
||||
elif version == 3:
|
||||
header_len = int.from_bytes(data[8:12], "little")
|
||||
offset = 12 + header_len
|
||||
else:
|
||||
raise ValueError(f"Unsupported CRX version: {version}.")
|
||||
|
||||
payload = data[offset:]
|
||||
if not payload.startswith(b"PK"):
|
||||
raise ValueError("CRX payload did not contain a ZIP archive.")
|
||||
return payload
|
||||
|
||||
|
||||
def _safe_extract_zip(archive_path: Path, target_dir: Path) -> None:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
root = target_dir.resolve()
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for member in archive.infolist():
|
||||
destination = (target_dir / member.filename).resolve()
|
||||
if not destination.is_relative_to(root):
|
||||
raise ValueError("Extension archive contains an unsafe path.")
|
||||
if member.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(member) as source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
|
||||
config = get_browser_config()
|
||||
path = str(extension_path)
|
||||
paths = list(config["extension_paths"])
|
||||
if path not in paths:
|
||||
paths.append(path)
|
||||
config["extensions_enabled"] = True
|
||||
config["extension_paths"] = paths
|
||||
plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
|
||||
return config
|
||||
|
||||
|
||||
def _read_manifest(extension_path: Path) -> dict[str, Any]:
|
||||
manifest_path = extension_path / "manifest.json"
|
||||
try:
|
||||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
57
plugins/_browser/helpers/playwright.py
Normal file
57
plugins/_browser/helpers/playwright.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from helpers import files
|
||||
|
||||
HEADLESS_SHELL_PATTERNS = (
|
||||
"chromium_headless_shell-*/chrome-*/headless_shell",
|
||||
"chromium_headless_shell-*/chrome-*/headless_shell.exe",
|
||||
)
|
||||
|
||||
FULL_CHROMIUM_PATTERNS = (
|
||||
"chromium-*/chrome-linux/chrome",
|
||||
"chromium-*/chrome-win/chrome.exe",
|
||||
)
|
||||
|
||||
|
||||
def get_playwright_cache_dir() -> str:
|
||||
return files.get_abs_path("tmp/playwright")
|
||||
|
||||
|
||||
def configure_playwright_env() -> str:
|
||||
cache_dir = get_playwright_cache_dir()
|
||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
|
||||
return cache_dir
|
||||
|
||||
|
||||
def get_playwright_binary(*, full_browser: bool = False) -> Path | None:
|
||||
cache_dir = Path(get_playwright_cache_dir())
|
||||
patterns = FULL_CHROMIUM_PATTERNS if full_browser else (HEADLESS_SHELL_PATTERNS + FULL_CHROMIUM_PATTERNS)
|
||||
for pattern in patterns:
|
||||
binary = next(cache_dir.glob(pattern), None)
|
||||
if binary and binary.exists():
|
||||
return binary
|
||||
return None
|
||||
|
||||
|
||||
def ensure_playwright_binary(*, full_browser: bool = False) -> Path:
|
||||
binary = get_playwright_binary(full_browser=full_browser)
|
||||
if binary:
|
||||
return binary
|
||||
|
||||
cache_dir = configure_playwright_env()
|
||||
env = os.environ.copy()
|
||||
env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
|
||||
install_command = ["playwright", "install", "chromium"]
|
||||
if not full_browser:
|
||||
install_command.append("--only-shell")
|
||||
subprocess.check_call(
|
||||
install_command,
|
||||
env=env,
|
||||
)
|
||||
|
||||
binary = get_playwright_binary(full_browser=full_browser)
|
||||
if not binary:
|
||||
raise RuntimeError("Playwright Chromium binary not found after installation")
|
||||
return binary
|
||||
623
plugins/_browser/helpers/runtime.py
Normal file
623
plugins/_browser/helpers/runtime.py
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import asyncio
|
||||
import base64
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from helpers import files
|
||||
from helpers.defer import DeferredTask
|
||||
from helpers.print_style import PrintStyle
|
||||
|
||||
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
|
||||
from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary
|
||||
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parents[1]
|
||||
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
|
||||
RUNTIME_DATA_KEY = "_browser_runtime"
|
||||
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
|
||||
|
||||
_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
|
||||
_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
|
||||
_LOCAL_HOST_RE = re.compile(
|
||||
r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?$",
|
||||
re.I,
|
||||
)
|
||||
_TYPED_HOST_RE = re.compile(
|
||||
r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3}|"
|
||||
r"(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d-]{2,63})(?::\d+)?$",
|
||||
re.I,
|
||||
)
|
||||
_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
|
||||
|
||||
def normalize_url(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Browser navigation requires a non-empty URL.")
|
||||
|
||||
def with_trailing_path(url: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme in {"http", "https"} and not parts.path:
|
||||
return urlunsplit((parts.scheme, parts.netloc, "/", parts.query, parts.fragment))
|
||||
return urlunsplit(parts)
|
||||
|
||||
try:
|
||||
host = re.split(r"[/?#]", raw, 1)[0] or ""
|
||||
if (
|
||||
not _URL_SCHEME_RE.match(raw)
|
||||
and not _SPECIAL_SCHEME_RE.match(raw)
|
||||
and not raw.startswith(("/", "?", "#", "."))
|
||||
and not re.search(r"\s", raw)
|
||||
and _TYPED_HOST_RE.match(host)
|
||||
):
|
||||
protocol = "http://" if _LOCAL_HOST_RE.match(host) else "https://"
|
||||
return with_trailing_path(protocol + raw)
|
||||
|
||||
parts = urlsplit(raw)
|
||||
if parts.scheme:
|
||||
return with_trailing_path(raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return with_trailing_path("https://" + raw)
|
||||
|
||||
|
||||
def _safe_context_id(context_id: str) -> str:
|
||||
return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserPage:
|
||||
id: int
|
||||
page: Any
|
||||
|
||||
|
||||
class BrowserRuntime:
|
||||
def __init__(self, context_id: str):
|
||||
self.context_id = str(context_id)
|
||||
self._core = _BrowserRuntimeCore(self.context_id)
|
||||
self._worker = DeferredTask(thread_name=f"BrowserRuntime-{self.context_id}")
|
||||
self._closed = False
|
||||
|
||||
async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if self._closed and method != "close":
|
||||
raise RuntimeError("Browser runtime is closed.")
|
||||
|
||||
async def runner():
|
||||
fn = getattr(self._core, method)
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
return await self._worker.execute_inside(runner)
|
||||
|
||||
async def close(self, delete_profile: bool = False) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
await self.call("close", delete_profile=delete_profile)
|
||||
finally:
|
||||
self._closed = True
|
||||
self._worker.kill(terminate_thread=True)
|
||||
|
||||
|
||||
class _BrowserRuntimeCore:
|
||||
def __init__(self, context_id: str):
|
||||
self.context_id = context_id
|
||||
self.safe_context_id = _safe_context_id(context_id)
|
||||
self.playwright = None
|
||||
self.context = None
|
||||
self.pages: dict[int, BrowserPage] = {}
|
||||
self.next_browser_id = 1
|
||||
self.last_interacted_browser_id: int | None = None
|
||||
self._content_helper_source: str | None = None
|
||||
|
||||
@property
|
||||
def profile_dir(self) -> Path:
|
||||
return Path(files.get_abs_path("tmp/browser/sessions", self.safe_context_id))
|
||||
|
||||
@property
|
||||
def downloads_dir(self) -> Path:
|
||||
return Path(files.get_abs_path("usr/downloads/browser"))
|
||||
|
||||
async def ensure_started(self) -> None:
|
||||
if self.context:
|
||||
return
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
self.profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
browser_config = get_browser_config()
|
||||
launch_config = build_browser_launch_config(browser_config)
|
||||
configure_playwright_env()
|
||||
browser_binary = ensure_playwright_binary(
|
||||
full_browser=launch_config["requires_full_browser"]
|
||||
)
|
||||
|
||||
self.playwright = await async_playwright().start()
|
||||
launch_kwargs: dict[str, Any] = {
|
||||
"user_data_dir": str(self.profile_dir),
|
||||
"headless": True,
|
||||
"accept_downloads": True,
|
||||
"downloads_path": str(self.downloads_dir),
|
||||
"viewport": DEFAULT_VIEWPORT,
|
||||
"screen": DEFAULT_VIEWPORT,
|
||||
"no_viewport": False,
|
||||
"args": launch_config["args"],
|
||||
}
|
||||
if launch_config["channel"]:
|
||||
launch_kwargs["channel"] = launch_config["channel"]
|
||||
else:
|
||||
launch_kwargs["executable_path"] = str(browser_binary)
|
||||
self.context = await self.playwright.chromium.launch_persistent_context(
|
||||
**launch_kwargs
|
||||
)
|
||||
self.context.set_default_timeout(30000)
|
||||
self.context.set_default_navigation_timeout(30000)
|
||||
await self.context.add_init_script(self._shadow_dom_script())
|
||||
await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
|
||||
|
||||
for page in list(self.context.pages):
|
||||
if page.url == "about:blank":
|
||||
try:
|
||||
await page.close()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
self._register_page(page)
|
||||
|
||||
async def open(self, url: str = "about:blank") -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
page = await self.context.new_page()
|
||||
browser_page = self._register_page(page)
|
||||
self.last_interacted_browser_id = browser_page.id
|
||||
if url and url != "about:blank":
|
||||
await self._goto(page, normalize_url(url))
|
||||
else:
|
||||
await self._settle(page)
|
||||
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
|
||||
|
||||
async def list(self) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
return {
|
||||
"browsers": [await self._state(browser_id) for browser_id in sorted(self.pages)],
|
||||
"last_interacted_browser_id": self.last_interacted_browser_id,
|
||||
}
|
||||
|
||||
async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
return await self._state(self._resolve_browser_id(browser_id))
|
||||
|
||||
async def navigate(self, browser_id: int | str | None, url: str) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await self._goto(page, normalize_url(url))
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def back(self, browser_id: int | str | None = None) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.go_back(wait_until="domcontentloaded", timeout=10000)
|
||||
await self._settle(page)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def forward(self, browser_id: int | str | None = None) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.go_forward(wait_until="domcontentloaded", timeout=10000)
|
||||
await self._settle(page)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def reload(self, browser_id: int | str | None = None) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.reload(wait_until="domcontentloaded", timeout=15000)
|
||||
await self._settle(page)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def content(
|
||||
self,
|
||||
browser_id: int | str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await self._ensure_content_helper(page)
|
||||
result = await page.evaluate(
|
||||
"(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
|
||||
payload or None,
|
||||
)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return result or {}
|
||||
|
||||
async def detail(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await self._ensure_content_helper(page)
|
||||
result = await page.evaluate(
|
||||
"(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)",
|
||||
reference_id,
|
||||
)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return result or {}
|
||||
|
||||
async def evaluate(self, browser_id: int | str | None, script: str) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
result = await page.evaluate(str(script or "undefined"))
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return {"result": result, "state": await self._state(resolved_id)}
|
||||
|
||||
async def click(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
|
||||
return await self._reference_action("click", browser_id, reference_id)
|
||||
|
||||
async def submit(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
|
||||
return await self._reference_action("submit", browser_id, reference_id)
|
||||
|
||||
async def scroll(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
|
||||
return await self._reference_action("scroll", browser_id, reference_id)
|
||||
|
||||
async def type(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
reference_id: int | str,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._reference_action("type", browser_id, reference_id, text)
|
||||
|
||||
async def type_submit(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
reference_id: int | str,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._reference_action("typeSubmit", browser_id, reference_id, text)
|
||||
|
||||
async def close_browser(self, browser_id: int | str | None = None) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.close()
|
||||
self.pages.pop(resolved_id, None)
|
||||
if self.last_interacted_browser_id == resolved_id:
|
||||
self.last_interacted_browser_id = next(iter(sorted(self.pages)), None)
|
||||
return await self.list()
|
||||
|
||||
async def close_all_browsers(self) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
for browser_id in list(self.pages):
|
||||
try:
|
||||
await self.pages[browser_id].page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.pages.clear()
|
||||
self.last_interacted_browser_id = None
|
||||
return {"browsers": [], "last_interacted_browser_id": None}
|
||||
|
||||
async def screenshot(
|
||||
self,
|
||||
browser_id: int | str | None = None,
|
||||
*,
|
||||
quality: int = 70,
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
image = await page.screenshot(type="jpeg", quality=max(20, min(95, int(quality))))
|
||||
return {
|
||||
"browser_id": resolved_id,
|
||||
"mime": "image/jpeg",
|
||||
"image": base64.b64encode(image).decode("ascii"),
|
||||
"state": await self._state(resolved_id),
|
||||
}
|
||||
|
||||
async def set_viewport(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
viewport = {
|
||||
"width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))),
|
||||
"height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))),
|
||||
}
|
||||
await page.set_viewport_size(viewport)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return {"state": await self._state(resolved_id), "viewport": viewport}
|
||||
|
||||
async def mouse(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
event_type: str,
|
||||
x: float,
|
||||
y: float,
|
||||
button: str = "left",
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
event_type = str(event_type or "click").lower()
|
||||
if event_type == "move":
|
||||
await page.mouse.move(float(x), float(y))
|
||||
elif event_type == "down":
|
||||
await page.mouse.down(button=button)
|
||||
elif event_type == "up":
|
||||
await page.mouse.up(button=button)
|
||||
else:
|
||||
await page.mouse.click(float(x), float(y), button=button)
|
||||
await self._settle(page, short=True)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def wheel(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
x: float,
|
||||
y: float,
|
||||
delta_x: float = 0,
|
||||
delta_y: float = 0,
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.mouse.move(float(x), float(y))
|
||||
await page.mouse.wheel(float(delta_x), float(delta_y))
|
||||
await self._settle(page, short=True)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def keyboard(
|
||||
self,
|
||||
browser_id: int | str | None,
|
||||
*,
|
||||
key: str = "",
|
||||
text: str = "",
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
if text:
|
||||
await page.keyboard.type(str(text))
|
||||
elif key:
|
||||
await page.keyboard.press(str(key))
|
||||
await self._settle(page, short=True)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return await self._state(resolved_id)
|
||||
|
||||
async def close(self, delete_profile: bool = False) -> None:
|
||||
for browser_id in list(self.pages):
|
||||
try:
|
||||
await self.pages[browser_id].page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.pages.clear()
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception as exc:
|
||||
PrintStyle.warning(f"Browser context close failed: {exc}")
|
||||
self.context = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception as exc:
|
||||
PrintStyle.warning(f"Playwright stop failed: {exc}")
|
||||
self.playwright = None
|
||||
self.last_interacted_browser_id = None
|
||||
if delete_profile:
|
||||
shutil.rmtree(self.profile_dir, ignore_errors=True)
|
||||
|
||||
async def _reference_action(
|
||||
self,
|
||||
helper_method: str,
|
||||
browser_id: int | str | None,
|
||||
reference_id: int | str,
|
||||
text: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await self._ensure_content_helper(page)
|
||||
if text is None:
|
||||
action = await page.evaluate(
|
||||
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref)",
|
||||
{"method": helper_method, "ref": reference_id},
|
||||
)
|
||||
else:
|
||||
action = await page.evaluate(
|
||||
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref, args.text)",
|
||||
{"method": helper_method, "ref": reference_id, "text": text},
|
||||
)
|
||||
await self._settle(page, short=False)
|
||||
self.last_interacted_browser_id = resolved_id
|
||||
return {"action": action or {}, "state": await self._state(resolved_id)}
|
||||
|
||||
async def _goto(self, page: Any, url: str) -> None:
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||
except PlaywrightTimeoutError:
|
||||
PrintStyle.warning(f"Browser navigation timed out after DOM handoff: {url}")
|
||||
await self._settle(page)
|
||||
|
||||
async def _settle(self, page: Any, short: bool = False) -> None:
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
try:
|
||||
await page.wait_for_load_state(
|
||||
"domcontentloaded",
|
||||
timeout=1000 if short else 5000,
|
||||
)
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
await asyncio.sleep(0.1 if short else 0.35)
|
||||
|
||||
async def _state(self, browser_id: int) -> dict[str, Any]:
|
||||
browser_page = self.pages.get(int(browser_id))
|
||||
if not browser_page:
|
||||
raise KeyError(f"Browser {browser_id} is not open.")
|
||||
page = browser_page.page
|
||||
try:
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
try:
|
||||
history_length = await page.evaluate("() => globalThis.history?.length || 0")
|
||||
except Exception:
|
||||
history_length = 0
|
||||
return {
|
||||
"id": browser_page.id,
|
||||
"currentUrl": page.url,
|
||||
"title": title,
|
||||
"canGoBack": bool(history_length and int(history_length) > 1),
|
||||
"canGoForward": False,
|
||||
"loading": False,
|
||||
}
|
||||
|
||||
def _register_page(self, page: Any) -> BrowserPage:
|
||||
existing = self._browser_id_for_page(page)
|
||||
if existing is not None:
|
||||
return self.pages[existing]
|
||||
browser_id = self.next_browser_id
|
||||
self.next_browser_id += 1
|
||||
browser_page = BrowserPage(id=browser_id, page=page)
|
||||
self.pages[browser_id] = browser_page
|
||||
|
||||
def on_close() -> None:
|
||||
self.pages.pop(browser_id, None)
|
||||
|
||||
page.on("close", on_close)
|
||||
return browser_page
|
||||
|
||||
def _browser_id_for_page(self, page: Any) -> int | None:
|
||||
for browser_id, browser_page in self.pages.items():
|
||||
if browser_page.page == page:
|
||||
return browser_id
|
||||
return None
|
||||
|
||||
def _resolve_browser_id(self, browser_id: int | str | None = None) -> int:
|
||||
if browser_id is None or str(browser_id).strip() == "":
|
||||
if self.last_interacted_browser_id in self.pages:
|
||||
return int(self.last_interacted_browser_id)
|
||||
if self.pages:
|
||||
return sorted(self.pages)[0]
|
||||
raise KeyError("No browser is open. Use action=open first.")
|
||||
value = str(browser_id).strip()
|
||||
if value.startswith("browser-"):
|
||||
value = value.split("-", 1)[1]
|
||||
resolved = int(value)
|
||||
if resolved not in self.pages:
|
||||
raise KeyError(f"Browser {resolved} is not open.")
|
||||
return resolved
|
||||
|
||||
def _page(self, browser_id: int) -> Any:
|
||||
return self.pages[int(browser_id)].page
|
||||
|
||||
async def _ensure_content_helper(self, page: Any) -> None:
|
||||
has_helper = await page.evaluate(
|
||||
"() => Boolean(globalThis.__spaceBrowserPageContent__?.capture)"
|
||||
)
|
||||
if has_helper:
|
||||
return
|
||||
if self._content_helper_source is None:
|
||||
self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
|
||||
await page.evaluate(self._content_helper_source)
|
||||
|
||||
@staticmethod
|
||||
def _shadow_dom_script() -> str:
|
||||
return """
|
||||
(() => {
|
||||
const original = Element.prototype.attachShadow;
|
||||
if (original && !original.__a0BrowserOpenShadowPatch) {
|
||||
const patched = function attachShadow(options) {
|
||||
return original.call(this, { ...(options || {}), mode: "open" });
|
||||
};
|
||||
patched.__a0BrowserOpenShadowPatch = true;
|
||||
Element.prototype.attachShadow = patched;
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
_runtimes: dict[str, BrowserRuntime] = {}
|
||||
_runtime_lock = threading.RLock()
|
||||
|
||||
|
||||
async def get_runtime(context_id: str, *, create: bool = True) -> BrowserRuntime | None:
|
||||
context_id = str(context_id or "").strip()
|
||||
if not context_id:
|
||||
raise ValueError("context_id is required")
|
||||
with _runtime_lock:
|
||||
runtime = _runtimes.get(context_id)
|
||||
if runtime is None and create:
|
||||
runtime = BrowserRuntime(context_id)
|
||||
_runtimes[context_id] = runtime
|
||||
return runtime
|
||||
|
||||
|
||||
async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None:
|
||||
context_id = str(context_id or "").strip()
|
||||
if not context_id:
|
||||
return
|
||||
with _runtime_lock:
|
||||
runtime = _runtimes.pop(context_id, None)
|
||||
if runtime:
|
||||
await runtime.close(delete_profile=delete_profile)
|
||||
|
||||
|
||||
def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
|
||||
task = DeferredTask(thread_name="BrowserCleanup")
|
||||
task.start_task(close_runtime, context_id, delete_profile=delete_profile)
|
||||
try:
|
||||
task.result_sync(timeout=30)
|
||||
finally:
|
||||
task.kill(terminate_thread=True)
|
||||
|
||||
|
||||
async def close_all_runtimes(*, delete_profiles: bool = False) -> None:
|
||||
with _runtime_lock:
|
||||
runtimes = list(_runtimes.values())
|
||||
_runtimes.clear()
|
||||
for runtime in runtimes:
|
||||
try:
|
||||
await runtime.close(delete_profile=delete_profiles)
|
||||
except Exception as exc:
|
||||
PrintStyle.warning(f"Browser runtime cleanup failed: {exc}")
|
||||
|
||||
|
||||
def close_all_runtimes_sync() -> None:
|
||||
task = DeferredTask(thread_name="BrowserCleanupAll")
|
||||
task.start_task(close_all_runtimes, delete_profiles=False)
|
||||
try:
|
||||
task.result_sync(timeout=30)
|
||||
finally:
|
||||
task.kill(terminate_thread=True)
|
||||
|
||||
|
||||
def known_context_ids() -> list[str]:
|
||||
with _runtime_lock:
|
||||
return sorted(_runtimes)
|
||||
|
||||
|
||||
atexit.register(close_all_runtimes_sync)
|
||||
47
plugins/_browser/hooks.py
Normal file
47
plugins/_browser/hooks.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers import files, plugins, yaml as yaml_helper
|
||||
from plugins._browser.helpers.config import (
|
||||
PLUGIN_NAME,
|
||||
browser_runtime_config,
|
||||
normalize_browser_config,
|
||||
)
|
||||
from plugins._browser.helpers.runtime import close_all_runtimes_sync
|
||||
|
||||
|
||||
def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
|
||||
entries = plugins.find_plugin_assets(
|
||||
plugins.CONFIG_FILE_NAME,
|
||||
plugin_name=PLUGIN_NAME,
|
||||
project_name=project_name,
|
||||
agent_profile=agent_profile,
|
||||
only_first=True,
|
||||
)
|
||||
path = entries[0].get("path", "") if entries else ""
|
||||
if path and files.exists(path):
|
||||
return files.read_file_json(path) or {}
|
||||
|
||||
plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME)
|
||||
default_path = (
|
||||
files.get_abs_path(plugin_dir, plugins.CONFIG_DEFAULT_FILE_NAME)
|
||||
if plugin_dir
|
||||
else ""
|
||||
)
|
||||
if default_path and files.exists(default_path):
|
||||
return yaml_helper.loads(files.read_file(default_path)) or {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_plugin_config(default=None, **kwargs):
|
||||
return normalize_browser_config(default)
|
||||
|
||||
|
||||
def save_plugin_config(settings=None, project_name="", agent_profile="", **kwargs):
|
||||
normalized = normalize_browser_config(settings)
|
||||
current = normalize_browser_config(
|
||||
_load_saved_browser_config(project_name=project_name, agent_profile=agent_profile)
|
||||
)
|
||||
if browser_runtime_config(normalized) != browser_runtime_config(current):
|
||||
close_all_runtimes_sync()
|
||||
return normalized
|
||||
9
plugins/_browser/plugin.yaml
Normal file
9
plugins/_browser/plugin.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: _browser
|
||||
title: Browser
|
||||
description: Built-in direct Playwright browser tool and WebUI viewer.
|
||||
version: 1.0.0
|
||||
always_enabled: false
|
||||
settings_sections:
|
||||
- external
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
48
plugins/_browser/prompts/agent.system.tool.browser.md
Normal file
48
plugins/_browser/prompts/agent.system.tool.browser.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
### browser
|
||||
direct Playwright browser control with visible WebUI viewer
|
||||
use for web browsing, page inspection, forms, downloads, and browser-only tasks
|
||||
state stays open per chat context
|
||||
refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
|
||||
|
||||
actions: open list state navigate back forward reload content detail click type submit type_submit scroll evaluate close close_all
|
||||
common args: action browser_id url ref text selector selectors script
|
||||
|
||||
workflow:
|
||||
- open creates a new browser and returns id/state
|
||||
- content returns readable page markdown with typed refs
|
||||
- detail inspects one ref, including link/image/input/button metadata
|
||||
- click/type/type_submit/submit/scroll use refs from latest content capture and return {action,state}
|
||||
- navigate/back/forward/reload return fresh state
|
||||
- list shows open browsers
|
||||
|
||||
examples:
|
||||
~~~json
|
||||
{
|
||||
"tool_name": "browser",
|
||||
"tool_args": {
|
||||
"action": "open",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
~~~json
|
||||
{
|
||||
"tool_name": "browser",
|
||||
"tool_args": {
|
||||
"action": "content",
|
||||
"browser_id": 1
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
~~~json
|
||||
{
|
||||
"tool_name": "browser",
|
||||
"tool_args": {
|
||||
"action": "click",
|
||||
"browser_id": 1,
|
||||
"ref": 3
|
||||
}
|
||||
}
|
||||
~~~
|
||||
107
plugins/_browser/tools/browser.py
Normal file
107
plugins/_browser/tools/browser.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from helpers.tool import Response, Tool
|
||||
from plugins._browser.helpers.runtime import get_runtime
|
||||
|
||||
|
||||
class Browser(Tool):
|
||||
async def execute(
|
||||
self,
|
||||
action: str = "",
|
||||
browser_id: int | str | None = None,
|
||||
url: str = "",
|
||||
ref: int | str | None = None,
|
||||
text: str = "",
|
||||
selector: str = "",
|
||||
selectors: list[str] | None = None,
|
||||
script: str = "",
|
||||
**kwargs: Any,
|
||||
) -> Response:
|
||||
action = str(action or self.method or "state").strip().lower().replace("-", "_")
|
||||
runtime = await get_runtime(self.agent.context.id)
|
||||
|
||||
try:
|
||||
if action == "open":
|
||||
result = await runtime.call("open", url or "about:blank")
|
||||
elif action == "list":
|
||||
result = await runtime.call("list")
|
||||
elif action == "state":
|
||||
result = await runtime.call("state", browser_id)
|
||||
elif action == "navigate":
|
||||
result = await runtime.call("navigate", browser_id, url)
|
||||
elif action == "back":
|
||||
result = await runtime.call("back", browser_id)
|
||||
elif action == "forward":
|
||||
result = await runtime.call("forward", browser_id)
|
||||
elif action == "reload":
|
||||
result = await runtime.call("reload", browser_id)
|
||||
elif action == "content":
|
||||
payload = self._selector_payload(selector, selectors)
|
||||
result = await runtime.call("content", browser_id, payload)
|
||||
elif action == "detail":
|
||||
result = await runtime.call("detail", browser_id, self._require_ref(ref))
|
||||
elif action == "click":
|
||||
result = await runtime.call("click", browser_id, self._require_ref(ref))
|
||||
elif action == "type":
|
||||
result = await runtime.call("type", browser_id, self._require_ref(ref), text)
|
||||
elif action == "submit":
|
||||
result = await runtime.call("submit", browser_id, self._require_ref(ref))
|
||||
elif action in {"type_submit", "typesubmit"}:
|
||||
result = await runtime.call(
|
||||
"type_submit",
|
||||
browser_id,
|
||||
self._require_ref(ref),
|
||||
text,
|
||||
)
|
||||
elif action == "scroll":
|
||||
result = await runtime.call("scroll", browser_id, self._require_ref(ref))
|
||||
elif action == "evaluate":
|
||||
result = await runtime.call("evaluate", browser_id, script)
|
||||
elif action == "close":
|
||||
result = await runtime.call("close_browser", browser_id)
|
||||
elif action == "close_all":
|
||||
result = await runtime.call("close_all_browsers")
|
||||
else:
|
||||
return Response(
|
||||
message=f"Unknown browser action: {action}",
|
||||
break_loop=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return Response(message=f"Browser {action} failed: {exc}", break_loop=False)
|
||||
|
||||
return Response(message=self._format_result(action, result), break_loop=False)
|
||||
|
||||
def get_log_object(self):
|
||||
return self.agent.context.log.log(
|
||||
type="tool",
|
||||
heading=f"icon://captive_portal {self.agent.agent_name}: Using browser",
|
||||
content="",
|
||||
kvps=self.args,
|
||||
_tool_name=self.name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _require_ref(ref: int | str | None) -> int | str:
|
||||
if ref is None or str(ref).strip() == "":
|
||||
raise ValueError("ref is required for this browser action")
|
||||
return ref
|
||||
|
||||
@staticmethod
|
||||
def _selector_payload(selector: str = "", selectors: list[str] | None = None) -> dict | None:
|
||||
if selectors:
|
||||
return {"selectors": selectors}
|
||||
if selector:
|
||||
return {"selector": selector}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_result(action: str, result: Any) -> str:
|
||||
if action == "content" and isinstance(result, dict):
|
||||
if set(result.keys()) == {"document"}:
|
||||
return str(result.get("document") or "")
|
||||
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||
|
||||
return json.dumps(result, indent=2, ensure_ascii=False, default=str)
|
||||
155
plugins/_browser/webui/browser-config-store.js
Normal file
155
plugins/_browser/webui/browser-config-store.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { fetchApi } from "/js/api.js";
|
||||
|
||||
const MODEL_CONFIG_API = "/plugins/_model_config/model_presets";
|
||||
|
||||
function normalizePathList(value) {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: String(value || "").split(/\r?\n/);
|
||||
const seen = new Set();
|
||||
const paths = [];
|
||||
for (const item of source) {
|
||||
const path = String(item || "").trim();
|
||||
if (!path || seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function ensureConfig(config) {
|
||||
if (!config || typeof config !== "object") return null;
|
||||
if (typeof config.extensions_enabled !== "boolean") {
|
||||
config.extensions_enabled = Boolean(config.extensions_enabled);
|
||||
}
|
||||
config.extension_paths = normalizePathList(config.extension_paths);
|
||||
config.model_preset = String(config.model_preset || "").trim();
|
||||
delete config.model;
|
||||
return config;
|
||||
}
|
||||
|
||||
export const store = createStore("browserConfig", {
|
||||
config: null,
|
||||
extensionPathsText: "",
|
||||
presets: [],
|
||||
presetsLoading: false,
|
||||
presetsError: "",
|
||||
_presetsLoaded: false,
|
||||
|
||||
async init(config) {
|
||||
this.bindConfig(config);
|
||||
await this.loadPresets();
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
this.config = null;
|
||||
this.extensionPathsText = "";
|
||||
this.presetsError = "";
|
||||
},
|
||||
|
||||
bindConfig(config) {
|
||||
const safeConfig = ensureConfig(config);
|
||||
if (!safeConfig) return;
|
||||
if (this.config === safeConfig) return;
|
||||
this.config = safeConfig;
|
||||
this.extensionPathsText = safeConfig.extension_paths.join("\n");
|
||||
},
|
||||
|
||||
setExtensionPathsText(value) {
|
||||
this.extensionPathsText = String(value || "");
|
||||
this.syncExtensionPaths();
|
||||
},
|
||||
|
||||
syncExtensionPaths() {
|
||||
const safeConfig = ensureConfig(this.config);
|
||||
if (!safeConfig) return;
|
||||
safeConfig.extension_paths = normalizePathList(this.extensionPathsText);
|
||||
},
|
||||
|
||||
hasPaths() {
|
||||
return this.pathCount() > 0;
|
||||
},
|
||||
|
||||
pathCount() {
|
||||
return normalizePathList(this.extensionPathsText).length;
|
||||
},
|
||||
|
||||
pathCountLabel() {
|
||||
const count = this.pathCount();
|
||||
if (!count) return "No extension paths configured";
|
||||
return `${count} path${count === 1 ? "" : "s"} configured`;
|
||||
},
|
||||
|
||||
extensionModeReady() {
|
||||
const safeConfig = ensureConfig(this.config);
|
||||
return Boolean(safeConfig?.extensions_enabled && this.pathCount());
|
||||
},
|
||||
|
||||
async loadPresets() {
|
||||
if (this._presetsLoaded || this.presetsLoading) return;
|
||||
this.presetsLoading = true;
|
||||
this.presetsError = "";
|
||||
try {
|
||||
const response = await fetchApi(MODEL_CONFIG_API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "get" }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
this.presets = Array.isArray(data?.presets)
|
||||
? data.presets.filter((preset) => String(preset?.name || "").trim())
|
||||
: [];
|
||||
this._presetsLoaded = true;
|
||||
} catch (error) {
|
||||
this.presets = [];
|
||||
this.presetsError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.presetsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectedPreset() {
|
||||
const selected = String(this.config?.model_preset || "").trim();
|
||||
if (!selected) return null;
|
||||
return this.presets.find((preset) => preset?.name === selected) || null;
|
||||
},
|
||||
|
||||
presetOptions() {
|
||||
const selected = String(this.config?.model_preset || "").trim();
|
||||
const options = this.presets.map((preset) => ({
|
||||
...preset,
|
||||
label: preset.name,
|
||||
missing: false,
|
||||
}));
|
||||
if (selected && this._presetsLoaded && !options.some((preset) => preset.name === selected)) {
|
||||
options.push({
|
||||
name: selected,
|
||||
label: `${selected} (missing)`,
|
||||
missing: true,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
selectedPresetSummary() {
|
||||
const selected = String(this.config?.model_preset || "").trim();
|
||||
if (!selected) return "Using the effective Main Model.";
|
||||
|
||||
const preset = this.selectedPreset();
|
||||
if (!preset) return `Preset "${selected}" is not available. Browser will fall back to the Main Model.`;
|
||||
|
||||
const chat = preset.chat || {};
|
||||
const parts = [chat.provider, chat.name].filter((item) => String(item || "").trim());
|
||||
return parts.length ? parts.join(" / ") : "This preset has no Main Model; Browser will fall back to the Main Model.";
|
||||
},
|
||||
|
||||
selectedPresetMissing() {
|
||||
const selected = String(this.config?.model_preset || "").trim();
|
||||
return Boolean(selected && this._presetsLoaded && !this.selectedPreset());
|
||||
},
|
||||
|
||||
openPresets() {
|
||||
void globalThis.openModal?.("/plugins/_model_config/webui/main.html");
|
||||
},
|
||||
});
|
||||
596
plugins/_browser/webui/browser-store.js
Normal file
596
plugins/_browser/webui/browser-store.js
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { getNamespacedClient } from "/js/websocket.js";
|
||||
import { store as chatInputStore } from "/components/chat/input/input-store.js";
|
||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
|
||||
|
||||
const websocket = getNamespacedClient("/ws");
|
||||
websocket.addHandlers(["ws_webui"]);
|
||||
|
||||
const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/browser-extensions";
|
||||
|
||||
function firstOk(response) {
|
||||
const result = response?.results?.find((item) => item?.ok);
|
||||
if (result) {
|
||||
const data = result.data || {};
|
||||
if (data.browser_error) {
|
||||
throw new Error(data.browser_error.error || data.browser_error.code || "Browser request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const error = response?.results?.find((item) => !item?.ok)?.error;
|
||||
if (error) throw new Error(error.error || error.code || "Browser request failed");
|
||||
return {};
|
||||
}
|
||||
|
||||
const model = {
|
||||
loading: true,
|
||||
error: "",
|
||||
status: null,
|
||||
contextId: "",
|
||||
browsers: [],
|
||||
activeBrowserId: null,
|
||||
address: "",
|
||||
frameSrc: "",
|
||||
frameState: null,
|
||||
connected: false,
|
||||
addressFocused: false,
|
||||
_frameOff: null,
|
||||
_stateOff: null,
|
||||
_lastFrameAt: 0,
|
||||
_floatingCleanup: null,
|
||||
_stageElement: null,
|
||||
_stageResizeObserver: null,
|
||||
_viewportSyncTimer: null,
|
||||
_lastViewportKey: "",
|
||||
extensionMenuOpen: false,
|
||||
extensionInstallUrl: "",
|
||||
extensionActionLoading: false,
|
||||
extensionActionMessage: "",
|
||||
extensionActionError: "",
|
||||
extensionsRoot: "",
|
||||
extensionsList: [],
|
||||
|
||||
async refreshStatus() {
|
||||
this.status = await callJsonApi("/plugins/_browser/status", {});
|
||||
},
|
||||
|
||||
async refreshExtensionsList() {
|
||||
const response = await callJsonApi("/plugins/_browser/extensions", { action: "list" });
|
||||
if (response?.ok) {
|
||||
this.extensionsRoot = response.root || EXTENSIONS_ROOT_FALLBACK;
|
||||
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
|
||||
}
|
||||
},
|
||||
|
||||
toggleExtensionsMenu() {
|
||||
this.extensionMenuOpen = !this.extensionMenuOpen;
|
||||
if (this.extensionMenuOpen) {
|
||||
this.extensionActionMessage = "";
|
||||
this.extensionActionError = "";
|
||||
void this.refreshExtensionsList();
|
||||
}
|
||||
},
|
||||
|
||||
closeExtensionsMenu() {
|
||||
this.extensionMenuOpen = false;
|
||||
},
|
||||
|
||||
resolveContextId() {
|
||||
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
|
||||
const selectedChat = globalThis.Alpine?.store?.("chats")?.selected;
|
||||
return globalThis.getContext?.() || urlContext || selectedChat || "";
|
||||
},
|
||||
|
||||
async openExtensionsSettings() {
|
||||
if (!pluginSettingsStore?.openConfig) {
|
||||
this.error = "Browser settings are unavailable.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.closeExtensionsMenu();
|
||||
await pluginSettingsStore.openConfig("_browser");
|
||||
await this.refreshAfterSettingsClose();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAfterSettingsClose() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
await this.refreshStatus();
|
||||
await this.refreshExtensionsList();
|
||||
this.connected = false;
|
||||
this.browsers = [];
|
||||
this.setActiveBrowserId(null);
|
||||
this.address = "";
|
||||
this.frameState = null;
|
||||
this.frameSrc = "";
|
||||
if (this.contextId) {
|
||||
await this.connectViewer();
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openExtensionsFolder() {
|
||||
this.closeExtensionsMenu();
|
||||
try {
|
||||
if (!this.extensionsRoot) {
|
||||
await this.refreshExtensionsList();
|
||||
}
|
||||
void fileBrowserStore.open(this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK);
|
||||
} catch (error) {
|
||||
this.extensionActionError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
|
||||
createExtensionWithAgent() {
|
||||
this._prefillAgentPrompt(
|
||||
[
|
||||
"Use the a0-browser-ext skill to create a new Chrome extension for Agent Zero's Browser.",
|
||||
"Start by asking me for the extension name, purpose, target websites, and required permissions.",
|
||||
`Create it under ${this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK}/<extension-slug> and keep permissions minimal.`,
|
||||
].join("\n")
|
||||
);
|
||||
},
|
||||
|
||||
askAgentInstallExtension() {
|
||||
const url = String(this.extensionInstallUrl || "").trim();
|
||||
this._prefillAgentPrompt(
|
||||
[
|
||||
"Use the a0-browser-ext skill to install and review a Chrome Web Store extension for Agent Zero's Browser.",
|
||||
url ? `Chrome Web Store URL or id: ${url}` : "Ask me for the Chrome Web Store URL or extension id first.",
|
||||
"Explain the permissions and any sandbox risk before enabling it.",
|
||||
].join("\n")
|
||||
);
|
||||
},
|
||||
|
||||
async installExtensionFromUrl() {
|
||||
const url = String(this.extensionInstallUrl || "").trim();
|
||||
this.extensionActionMessage = "";
|
||||
this.extensionActionError = "";
|
||||
if (!url) {
|
||||
this.extensionActionError = "Paste a Chrome Web Store URL or extension id first.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.extensionActionLoading = true;
|
||||
try {
|
||||
const response = await callJsonApi("/plugins/_browser/extensions", {
|
||||
action: "install_web_store",
|
||||
url,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Install failed.");
|
||||
}
|
||||
this.extensionInstallUrl = "";
|
||||
this.extensionActionMessage = `Installed ${response.name || response.id}. Browser sessions restart when extension settings change.`;
|
||||
await this.refreshStatus();
|
||||
await this.refreshExtensionsList();
|
||||
} catch (error) {
|
||||
this.extensionActionError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.extensionActionLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
_prefillAgentPrompt(prompt) {
|
||||
chatInputStore.message = prompt;
|
||||
chatInputStore.adjustTextareaHeight?.();
|
||||
chatInputStore.focus?.();
|
||||
this.closeExtensionsMenu();
|
||||
},
|
||||
|
||||
async onOpen(element = null) {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.setupFloatingModal(element);
|
||||
this.contextId = this.resolveContextId();
|
||||
try {
|
||||
await this.refreshStatus();
|
||||
await this.connectViewer();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async connectViewer() {
|
||||
if (!this.contextId) {
|
||||
this.connected = false;
|
||||
this.error = "No active chat context is selected.";
|
||||
return;
|
||||
}
|
||||
this.error = "";
|
||||
await this._bindSocketEvents();
|
||||
const response = await websocket.request(
|
||||
"browser_viewer_subscribe",
|
||||
{
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
},
|
||||
{ timeoutMs: 10000 },
|
||||
);
|
||||
const data = firstOk(response);
|
||||
this.browsers = data.browsers || [];
|
||||
this.setActiveBrowserId(data.active_browser_id || this.activeBrowserId || null);
|
||||
this.connected = true;
|
||||
this.queueViewportSync(true);
|
||||
},
|
||||
|
||||
async _bindSocketEvents() {
|
||||
if (!this._frameOff) {
|
||||
const frameHandler = ({ data }) => {
|
||||
if (data?.context_id !== this.contextId) return;
|
||||
this.browsers = data.browsers || this.browsers;
|
||||
this.setActiveBrowserId(data.browser_id || data.state?.id || this.activeBrowserId);
|
||||
this.frameState = data.state || null;
|
||||
if (!this.addressFocused && data.state?.currentUrl) {
|
||||
this.address = data.state.currentUrl;
|
||||
}
|
||||
this.frameSrc = data.image ? `data:${data.mime || "image/jpeg"};base64,${data.image}` : "";
|
||||
if (!data.image && !data.state) {
|
||||
this.setActiveBrowserId(null);
|
||||
this.frameState = null;
|
||||
this.frameSrc = "";
|
||||
}
|
||||
this._lastFrameAt = Date.now();
|
||||
};
|
||||
await websocket.on("browser_viewer_frame", frameHandler);
|
||||
this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
|
||||
}
|
||||
if (!this._stateOff) {
|
||||
const stateHandler = ({ data }) => {
|
||||
if (data?.context_id !== this.contextId) return;
|
||||
this.browsers = data.browsers || [];
|
||||
this.setActiveBrowserId(data.last_interacted_browser_id || this.firstBrowserId());
|
||||
this.queueViewportSync(true);
|
||||
};
|
||||
await websocket.on("browser_viewer_state", stateHandler);
|
||||
this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
|
||||
}
|
||||
},
|
||||
|
||||
async command(command, extra = {}) {
|
||||
this.error = "";
|
||||
const previousActiveBrowserId = this.activeBrowserId;
|
||||
try {
|
||||
const response = await websocket.request(
|
||||
"browser_viewer_command",
|
||||
{
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
command,
|
||||
...extra,
|
||||
},
|
||||
{ timeoutMs: 20000 },
|
||||
);
|
||||
const data = firstOk(response);
|
||||
this.browsers = data.browsers || this.browsers;
|
||||
const result = data.result || {};
|
||||
this.setActiveBrowserId(
|
||||
result.id
|
||||
|| result.state?.id
|
||||
|| result.last_interacted_browser_id
|
||||
|| data.last_interacted_browser_id
|
||||
|| this.firstBrowserId()
|
||||
);
|
||||
if (!this.activeBrowserId) {
|
||||
this.frameState = null;
|
||||
this.frameSrc = "";
|
||||
}
|
||||
if (result.state?.currentUrl || result.currentUrl) {
|
||||
this.address = result.state?.currentUrl || result.currentUrl;
|
||||
}
|
||||
const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
|
||||
if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
|
||||
await this.connectViewer();
|
||||
}
|
||||
this.queueViewportSync(true);
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
|
||||
async go() {
|
||||
const url = String(this.address || "").trim();
|
||||
if (!url) return;
|
||||
this.addressFocused = false;
|
||||
globalThis.document?.activeElement?.blur?.();
|
||||
if (this.activeBrowserId) {
|
||||
await this.command("navigate", { url });
|
||||
} else {
|
||||
await this.command("open", { url });
|
||||
}
|
||||
},
|
||||
|
||||
onAddressFocus() {
|
||||
this.addressFocused = true;
|
||||
},
|
||||
|
||||
onAddressBlur() {
|
||||
this.addressFocused = false;
|
||||
if (this.frameState?.currentUrl && !String(this.address || "").trim()) {
|
||||
this.address = this.frameState.currentUrl;
|
||||
}
|
||||
},
|
||||
|
||||
async selectBrowser(id) {
|
||||
if (String(id || "").trim() === "") {
|
||||
await this.command("open", { url: "about:blank" });
|
||||
return;
|
||||
}
|
||||
this.setActiveBrowserId(id);
|
||||
if (this.contextId) {
|
||||
await this.connectViewer();
|
||||
}
|
||||
},
|
||||
|
||||
firstBrowserId() {
|
||||
const first = Array.isArray(this.browsers) ? this.browsers[0] : null;
|
||||
return first?.id || null;
|
||||
},
|
||||
|
||||
setActiveBrowserId(id) {
|
||||
const previous = this.activeBrowserId;
|
||||
const numeric = Number(id) || null;
|
||||
const exists = !numeric || !Array.isArray(this.browsers) || this.browsers.some((browser) => Number(browser.id) === numeric);
|
||||
this.activeBrowserId = exists ? numeric : null;
|
||||
if (this.activeBrowserId !== previous) {
|
||||
this._lastViewportKey = "";
|
||||
}
|
||||
},
|
||||
|
||||
pointerCoordinatesFor(event, element = null) {
|
||||
const target = element || event?.currentTarget;
|
||||
if (!target) return null;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const naturalWidth = target.naturalWidth || rect.width;
|
||||
const naturalHeight = target.naturalHeight || rect.height;
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / Math.max(1, rect.width)) * naturalWidth,
|
||||
y: ((event.clientY - rect.top) / Math.max(1, rect.height)) * naturalHeight,
|
||||
};
|
||||
},
|
||||
|
||||
currentViewportSize() {
|
||||
const stage = this._stageElement;
|
||||
if (!stage) return null;
|
||||
const width = Math.floor(stage.clientWidth || 0);
|
||||
const height = Math.floor(stage.clientHeight || 0);
|
||||
if (width < 80 || height < 80) return null;
|
||||
return {
|
||||
width: Math.max(320, width),
|
||||
height: Math.max(200, height),
|
||||
};
|
||||
},
|
||||
|
||||
queueViewportSync(force = false) {
|
||||
if (this._viewportSyncTimer) {
|
||||
globalThis.clearTimeout(this._viewportSyncTimer);
|
||||
}
|
||||
this._viewportSyncTimer = globalThis.setTimeout(() => {
|
||||
this._viewportSyncTimer = null;
|
||||
void this.syncViewport(force);
|
||||
}, force ? 0 : 80);
|
||||
},
|
||||
|
||||
async syncViewport(force = false) {
|
||||
if (!this.contextId || !this.activeBrowserId) return;
|
||||
const viewport = this.currentViewportSize();
|
||||
if (!viewport) return;
|
||||
const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
|
||||
if (!force && this._lastViewportKey === key) return;
|
||||
try {
|
||||
await websocket.emit("browser_viewer_input", {
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
input_type: "viewport",
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
this._lastViewportKey = key;
|
||||
} catch (error) {
|
||||
this._lastViewportKey = "";
|
||||
console.warn("Browser viewport sync failed", error);
|
||||
}
|
||||
},
|
||||
|
||||
async sendMouse(eventType, event) {
|
||||
if (!this.activeBrowserId || !event?.currentTarget) return;
|
||||
const pointer = this.pointerCoordinatesFor(event);
|
||||
if (!pointer) return;
|
||||
await websocket.emit("browser_viewer_input", {
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
input_type: "mouse",
|
||||
event_type: eventType,
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
button: "left",
|
||||
});
|
||||
},
|
||||
|
||||
async sendWheel(event) {
|
||||
if (!this.activeBrowserId || !event) return;
|
||||
const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
|
||||
const pointer = this.pointerCoordinatesFor(event, image);
|
||||
if (!pointer) return;
|
||||
await websocket.emit("browser_viewer_input", {
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
input_type: "wheel",
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
delta_x: Number(event.deltaX || 0),
|
||||
delta_y: Number(event.deltaY || 0),
|
||||
});
|
||||
},
|
||||
|
||||
async sendKey(event) {
|
||||
if (!this.activeBrowserId) return;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
|
||||
if (editable) return;
|
||||
event.preventDefault();
|
||||
const printable = event.key && event.key.length === 1;
|
||||
await websocket.emit("browser_viewer_input", {
|
||||
context_id: this.contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
input_type: "keyboard",
|
||||
key: printable ? "" : event.key,
|
||||
text: printable ? event.key : "",
|
||||
});
|
||||
},
|
||||
|
||||
async cleanup() {
|
||||
if (this.contextId) {
|
||||
try {
|
||||
await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
|
||||
} catch {}
|
||||
}
|
||||
this._frameOff?.();
|
||||
this._stateOff?.();
|
||||
this._frameOff = null;
|
||||
this._stateOff = null;
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
this._stageResizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver = null;
|
||||
this._stageElement = null;
|
||||
if (this._viewportSyncTimer) {
|
||||
globalThis.clearTimeout(this._viewportSyncTimer);
|
||||
this._viewportSyncTimer = null;
|
||||
}
|
||||
this._lastViewportKey = "";
|
||||
this.extensionMenuOpen = false;
|
||||
this.extensionActionLoading = false;
|
||||
this.connected = false;
|
||||
},
|
||||
|
||||
setupFloatingModal(element = null) {
|
||||
this._floatingCleanup?.();
|
||||
const root = element || globalThis.document?.querySelector(".browser-panel");
|
||||
const modal = root?.closest?.(".modal");
|
||||
const inner = modal?.querySelector?.(".modal-inner");
|
||||
const body = modal?.querySelector?.(".modal-bd");
|
||||
const header = modal?.querySelector?.(".modal-header");
|
||||
const stage = root?.querySelector?.(".browser-stage");
|
||||
if (!modal || !inner || !header) return;
|
||||
modal.classList.add("modal-floating");
|
||||
inner.classList.add("browser-modal");
|
||||
body?.classList?.add("browser-modal-body");
|
||||
this._stageElement = stage || null;
|
||||
|
||||
const rect = inner.getBoundingClientRect();
|
||||
inner.style.left = `${Math.max(8, rect.left)}px`;
|
||||
inner.style.top = `${Math.max(8, rect.top)}px`;
|
||||
inner.style.transform = "none";
|
||||
|
||||
let drag = null;
|
||||
let resizeObserver = null;
|
||||
const viewportGap = 8;
|
||||
const clampPosition = (left, top) => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
|
||||
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
|
||||
return {
|
||||
left: Math.min(Math.max(viewportGap, left), maxLeft),
|
||||
top: Math.min(Math.max(viewportGap, top), maxTop),
|
||||
};
|
||||
};
|
||||
const clampGeometry = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const left = Math.max(viewportGap, bounds.left);
|
||||
const top = Math.max(viewportGap, bounds.top);
|
||||
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
|
||||
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
|
||||
if (bounds.width > maxWidth) {
|
||||
inner.style.width = `${maxWidth}px`;
|
||||
}
|
||||
if (bounds.height > maxHeight) {
|
||||
inner.style.height = `${maxHeight}px`;
|
||||
}
|
||||
const next = clampPosition(left, top);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - next.left - viewportGap)}px`;
|
||||
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - next.top - viewportGap)}px`;
|
||||
this.queueViewportSync();
|
||||
};
|
||||
clampGeometry();
|
||||
globalThis.addEventListener("resize", clampGeometry);
|
||||
if (globalThis.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(clampGeometry);
|
||||
resizeObserver.observe(inner);
|
||||
if (stage) {
|
||||
this._stageResizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver = new ResizeObserver(() => this.queueViewportSync());
|
||||
this._stageResizeObserver.observe(stage);
|
||||
}
|
||||
}
|
||||
globalThis.requestAnimationFrame(() => this.queueViewportSync(true));
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!drag) return;
|
||||
const next = clampPosition(
|
||||
drag.left + event.clientX - drag.x,
|
||||
drag.top + event.clientY - drag.y,
|
||||
);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
clampGeometry();
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
drag = null;
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
try {
|
||||
header.releasePointerCapture?.(header.__browserPanelPointerId || 0);
|
||||
} catch {}
|
||||
};
|
||||
const onPointerDown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.("button, input, select, textarea, a")) return;
|
||||
const current = inner.getBoundingClientRect();
|
||||
drag = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: current.left,
|
||||
top: current.top,
|
||||
};
|
||||
header.__browserPanelPointerId = event.pointerId;
|
||||
header.setPointerCapture?.(event.pointerId);
|
||||
globalThis.addEventListener("pointermove", onPointerMove);
|
||||
globalThis.addEventListener("pointerup", onPointerUp);
|
||||
event.preventDefault();
|
||||
};
|
||||
header.addEventListener("pointerdown", onPointerDown);
|
||||
|
||||
this._floatingCleanup = () => {
|
||||
header.removeEventListener("pointerdown", onPointerDown);
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
globalThis.removeEventListener("resize", clampGeometry);
|
||||
resizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver = null;
|
||||
};
|
||||
},
|
||||
|
||||
get activeTitle() {
|
||||
return this.frameState?.title || "Browser";
|
||||
},
|
||||
|
||||
get activeUrl() {
|
||||
return this.frameState?.currentUrl || this.address || "about:blank";
|
||||
},
|
||||
};
|
||||
|
||||
export const store = createStore("browserPage", model);
|
||||
225
plugins/_browser/webui/config.html
Normal file
225
plugins/_browser/webui/config.html
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Browser Settings</title>
|
||||
<script type="module">
|
||||
import { store } from "/plugins/_browser/webui/browser-config-store.js";
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="$store.browserConfig && config">
|
||||
<div
|
||||
class="browser-config-sections"
|
||||
x-init="$store.browserConfig.init(config)"
|
||||
x-effect="$store.browserConfig.bindConfig(config)"
|
||||
x-destroy="$store.browserConfig.cleanup()"
|
||||
>
|
||||
<div class="browser-config-card">
|
||||
<div class="section-title">Browser Model Preset</div>
|
||||
<div class="section-description">
|
||||
Choose an optional Model Configuration preset for Browser-owned model helpers. Leave it
|
||||
on default to follow the effective Main Model.
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Preset</div>
|
||||
<div class="field-description" x-text="$store.browserConfig.selectedPresetSummary()"></div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<select x-model="config.model_preset" :disabled="$store.browserConfig.presetsLoading">
|
||||
<option value="">Default Main Model</option>
|
||||
<template x-for="preset in $store.browserConfig.presetOptions()" :key="preset.name">
|
||||
<option :value="preset.name" x-text="preset.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-note" x-show="$store.browserConfig.presetsLoading">
|
||||
<span class="material-symbols-outlined spinning">progress_activity</span>
|
||||
<span>Loading model presets...</span>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-warning" x-show="$store.browserConfig.selectedPresetMissing()">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>The saved preset is missing. Browser will use the effective Main Model until you choose another preset.</span>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-note" x-show="$store.browserConfig.presetsError">
|
||||
<span class="material-symbols-outlined">error</span>
|
||||
<span x-text="$store.browserConfig.presetsError"></span>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-actions">
|
||||
<button type="button" class="btn btn-field" @click="$store.browserConfig.openPresets()">
|
||||
<span class="material-symbols-outlined">tune</span>
|
||||
<span>Edit Presets</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-card">
|
||||
<div class="section-title">Chrome Extensions</div>
|
||||
<div class="section-description">
|
||||
Load unpacked Chromium extensions into the Browser tool. When extensions are active,
|
||||
Browser switches from Playwright's lightweight headless shell to bundled Chromium so
|
||||
the extensions can actually load.
|
||||
</div>
|
||||
|
||||
<div class="browser-config-warning">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>
|
||||
Browser extensions run inside the Docker browser sandbox, but malicious or buggy
|
||||
extensions can still damage that sandboxed environment. Install only extensions you
|
||||
trust and keep permissions as small as possible.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Enable extensions</div>
|
||||
<div class="field-description">
|
||||
Turn this on only when you have unpacked extension folders ready. Saving changes
|
||||
restarts active Browser sessions so the new launch mode applies immediately.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.extensions_enabled" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Extension directories</div>
|
||||
<div class="field-description">
|
||||
One unpacked extension directory per line. Use paths that are visible inside the
|
||||
runtime environment itself, especially when Agent Zero is running in Docker.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<textarea
|
||||
:value="$store.browserConfig.extensionPathsText"
|
||||
@input="$store.browserConfig.setExtensionPathsText($event.target.value)"
|
||||
rows="6"
|
||||
placeholder="/a0/usr/browser-extensions/my-extension"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-note">
|
||||
<span class="material-symbols-outlined">info</span>
|
||||
<span>
|
||||
This first version supports unpacked extension folders only. Chrome Web Store installs
|
||||
and `.crx` files are out of scope for now.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-note">
|
||||
<span class="material-symbols-outlined">deployed_code</span>
|
||||
<span>
|
||||
Playwright currently requires a persistent Chromium context for extension loading, so
|
||||
Browser stays in its faster headless-shell mode until valid extension folders are both
|
||||
configured and enabled.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="browser-config-pill-row">
|
||||
<span class="browser-config-pill" x-text="$store.browserConfig.pathCountLabel()"></span>
|
||||
<span class="browser-config-pill tone-active" x-show="$store.browserConfig.extensionModeReady()">
|
||||
Extension mode ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.browser-config-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.browser-config-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.browser-config-card textarea {
|
||||
min-height: 132px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
}
|
||||
|
||||
.browser-config-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browser-config-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.browser-config-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid color-mix(in srgb, #d97706 44%, var(--color-border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #d97706 14%, var(--color-background));
|
||||
color: color-mix(in srgb, var(--color-text) 86%, #92400e);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.browser-config-warning .material-symbols-outlined {
|
||||
color: #b45309;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.browser-config-pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browser-config-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 88%, transparent);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.browser-config-pill.tone-active {
|
||||
color: #1b5e20;
|
||||
border-color: rgba(27, 94, 32, 0.18);
|
||||
background: rgba(46, 125, 50, 0.12);
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
556
plugins/_browser/webui/main.html
Normal file
556
plugins/_browser/webui/main.html
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
<html class="browser-modal">
|
||||
<head>
|
||||
<title>Browser</title>
|
||||
<script type="module">
|
||||
import { store } from "/plugins/_browser/webui/browser-store.js";
|
||||
</script>
|
||||
</head>
|
||||
<body class="browser-modal-body">
|
||||
<div x-data>
|
||||
<template x-if="$store.browserPage">
|
||||
<div
|
||||
class="browser-panel"
|
||||
x-create="$store.browserPage.onOpen($el)"
|
||||
x-destroy="$store.browserPage.cleanup()"
|
||||
@keydown.window="$store.browserPage.sendKey($event)"
|
||||
>
|
||||
<div class="browser-meta">
|
||||
<div class="browser-meta-top">
|
||||
<div class="browser-titleline">
|
||||
<span
|
||||
class="browser-live-dot"
|
||||
:class="{ active: $store.browserPage.connected && $store.browserPage.frameSrc }"
|
||||
></span>
|
||||
<span class="browser-title">Browser</span>
|
||||
<span class="browser-id" x-show="$store.browserPage.activeBrowserId" x-text="'#' + $store.browserPage.activeBrowserId"></span>
|
||||
</div>
|
||||
<div class="browser-session-controls">
|
||||
<select class="browser-select" x-model="$store.browserPage.activeBrowserId" @change="$store.browserPage.selectBrowser($event.target.value)">
|
||||
<option value="">New Browser</option>
|
||||
<template x-for="browser in $store.browserPage.browsers" :key="browser.id">
|
||||
<option :value="browser.id" x-text="'#' + browser.id + ' ' + (browser.title || browser.currentUrl || 'about:blank')"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div
|
||||
class="browser-extension-menu"
|
||||
@click.outside="$store.browserPage.closeExtensionsMenu()"
|
||||
@keydown.escape.window="$store.browserPage.closeExtensionsMenu()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon-action browser-extensions"
|
||||
title="Browser extensions"
|
||||
aria-label="Browser extensions"
|
||||
@click.stop="$store.browserPage.toggleExtensionsMenu()"
|
||||
:aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
|
||||
:class="{ 'is-active': $store.browserPage.status?.extensions?.active }"
|
||||
>
|
||||
<span class="material-symbols-outlined">extension</span>
|
||||
</button>
|
||||
<div
|
||||
class="browser-extension-dropdown"
|
||||
x-show="$store.browserPage.extensionMenuOpen"
|
||||
x-transition
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="browser-extension-warning">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>
|
||||
Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still damage that environment. Review what you install.
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
<span>+ Create New with A0</span>
|
||||
</button>
|
||||
<div class="browser-extension-url">
|
||||
<label for="browser-extension-url">Chrome Web Store URL</label>
|
||||
<input
|
||||
id="browser-extension-url"
|
||||
type="url"
|
||||
x-model="$store.browserPage.extensionInstallUrl"
|
||||
@keydown.enter.prevent="$store.browserPage.installExtensionFromUrl()"
|
||||
placeholder="https://chromewebstore.google.com/detail/..."
|
||||
/>
|
||||
<div class="browser-extension-url-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ok"
|
||||
@click="$store.browserPage.installExtensionFromUrl()"
|
||||
:disabled="$store.browserPage.extensionActionLoading"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
|
||||
<span>Install URL</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-field" @click="$store.browserPage.askAgentInstallExtension()">
|
||||
<span class="material-symbols-outlined">psychology_alt</span>
|
||||
<span>Ask A0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsFolder()">
|
||||
<span class="material-symbols-outlined">folder_open</span>
|
||||
<span>My Browser Extensions</span>
|
||||
</button>
|
||||
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
|
||||
<span class="material-symbols-outlined">tune</span>
|
||||
<span>Browser Extension Settings</span>
|
||||
</button>
|
||||
<div class="browser-extension-message" x-show="$store.browserPage.extensionActionMessage" x-text="$store.browserPage.extensionActionMessage"></div>
|
||||
<div class="browser-extension-error" x-show="$store.browserPage.extensionActionError" x-text="$store.browserPage.extensionActionError"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-icon-action browser-close" title="Close Browser" @click="$confirmClick($event, () => $store.browserPage.command('close'))" :disabled="!$store.browserPage.activeBrowserId">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="browser-toolbar">
|
||||
<div class="browser-navigation">
|
||||
<button class="btn btn-icon-action" title="Back" @click="$store.browserPage.command('back')" :disabled="!$store.browserPage.activeBrowserId">
|
||||
<span class="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<button class="btn btn-icon-action" title="Forward" @click="$store.browserPage.command('forward')" :disabled="!$store.browserPage.activeBrowserId">
|
||||
<span class="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
<button class="btn btn-icon-action" title="Reload" @click="$store.browserPage.command('reload')" :disabled="!$store.browserPage.activeBrowserId">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
|
||||
<span class="material-symbols-outlined browser-address-icon">language</span>
|
||||
<input
|
||||
class="browser-address"
|
||||
x-model="$store.browserPage.address"
|
||||
@focus="$store.browserPage.onAddressFocus()"
|
||||
@blur="$store.browserPage.onAddressBlur()"
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="browser-status" x-show="$store.browserPage.loading">
|
||||
<span class="material-symbols-outlined spinning">progress_activity</span>
|
||||
<span>Connecting browser...</span>
|
||||
</div>
|
||||
<div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
|
||||
|
||||
<div
|
||||
class="browser-stage"
|
||||
tabindex="0"
|
||||
@click="$el.focus()"
|
||||
@wheel.prevent="$store.browserPage.sendWheel($event)"
|
||||
>
|
||||
<template x-if="$store.browserPage.frameSrc">
|
||||
<img
|
||||
class="browser-frame"
|
||||
:src="$store.browserPage.frameSrc"
|
||||
@click="$store.browserPage.sendMouse('click', $event)"
|
||||
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.loading">
|
||||
<div class="browser-empty">
|
||||
<span class="material-symbols-outlined">captive_portal</span>
|
||||
<button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open Browser</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-inner.browser-modal {
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
width: min(78vw, 1120px);
|
||||
height: min(88vh, 900px);
|
||||
min-width: min(320px, calc(100vw - 16px));
|
||||
min-height: min(480px, calc(100vh - 16px));
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
resize: both;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
|
||||
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
|
||||
}
|
||||
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-floating .modal-inner {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-header {
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.75rem 0.35rem 1rem;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-title {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-close {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-bd.browser-modal-body {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal .modal-bd.browser-modal-body > div[x-data] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browser-panel {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browser-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "nav address";
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 7px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 90%, transparent);
|
||||
}
|
||||
|
||||
.browser-navigation {
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.browser-address-form {
|
||||
grid-area: address;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.browser-address-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 18px;
|
||||
opacity: 0.58;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-address,
|
||||
.browser-select {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
background: var(--color-input);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.browser-address {
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.browser-select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browser-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.browser-meta-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.browser-titleline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browser-session-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browser-session-controls .browser-select {
|
||||
width: min(320px, 52cqw);
|
||||
}
|
||||
|
||||
.browser-session-controls .browser-extensions.is-active {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.browser-extension-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.browser-extension-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
padding: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
|
||||
border-radius: 7px;
|
||||
background: var(--color-background);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.browser-extension-dropdown .dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-extension-dropdown .dropdown-item:hover {
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.browser-extension-warning,
|
||||
.browser-extension-message,
|
||||
.browser-extension-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.browser-extension-warning {
|
||||
border: 1px solid color-mix(in srgb, #d97706 42%, var(--color-border));
|
||||
background: color-mix(in srgb, #d97706 14%, var(--color-background));
|
||||
color: color-mix(in srgb, var(--color-text) 86%, #92400e);
|
||||
}
|
||||
|
||||
.browser-extension-warning .material-symbols-outlined {
|
||||
color: #b45309;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.browser-extension-url {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
|
||||
border-radius: 7px;
|
||||
background: var(--color-panel);
|
||||
}
|
||||
|
||||
.browser-extension-url label {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.browser-extension-url input {
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--color-input);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.browser-extension-url-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.browser-extension-url-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.browser-extension-message {
|
||||
background: color-mix(in srgb, #15803d 12%, var(--color-background));
|
||||
color: color-mix(in srgb, var(--color-text) 88%, #166534);
|
||||
}
|
||||
|
||||
.browser-extension-error {
|
||||
background: color-mix(in srgb, #be123c 12%, var(--color-background));
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.browser-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #777;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.browser-live-dot.active {
|
||||
background: #2e7d32;
|
||||
box-shadow: 0 0 0 4px rgba(46, 125, 50, 0.13);
|
||||
}
|
||||
|
||||
.browser-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.browser-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.browser-id {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.browser-stage {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.browser-status,
|
||||
.browser-error,
|
||||
.browser-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.browser-status,
|
||||
.browser-error {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.browser-error {
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
@container (max-width: 460px) {
|
||||
.browser-meta-top {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.browser-session-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browser-session-controls .browser-select {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.browser-extension-dropdown {
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: min(296px, calc(100vw - 72px));
|
||||
}
|
||||
|
||||
.browser-address,
|
||||
.browser-select {
|
||||
min-height: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue