From d277955a9aa7f275d429d50272c5f3fc6c328221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B1=D1=8B=D0=BB=D0=BA=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87=20=D0=A4=D1=91=D0=B4=D0=BE=D1=80?= Date: Tue, 7 Apr 2026 22:01:53 +0300 Subject: [PATCH] add api for post browser-use tasks --- .env.example | 8 ++- api/Dockerfile | 13 +++++ api/README.md | 17 ++++++ api/__init__.py | 0 api/browser_rpc_client.py | 31 +++++++++++ api/config.py | 16 ++++++ api/main.py | 112 ++++++++++++++++++++++++++++++++++++++ api/requirements.txt | 4 ++ api/schemas.py | 34 ++++++++++-- api/task_store.py | 69 +++++++++++++++++++++++ docker-compose.yml | 24 +++++++- 11 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/__init__.py create mode 100644 api/browser_rpc_client.py create mode 100644 api/config.py create mode 100644 api/main.py create mode 100644 api/requirements.txt create mode 100644 api/task_store.py diff --git a/.env.example b/.env.example index 30e3b386..16fbeacf 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,10 @@ TELEGRAM_ALLOWED_USERS= TELEGRAM_HOME_CHANNEL= BROWSER_URL=http://browser:9222 -BROWSER_VIEW_URL= \ No newline at end of file +BROWSER_VIEW_URL= + +BROWSER_API_HOST=0.0.0.0 +BROWSER_API_PORT=8088 +BROWSER_USE_RPC_URL=http://browser:8787/run +BROWSER_USE_RPC_TIMEOUT=900 +BROWSER_API_MAX_CONCURRENCY=2 \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 00000000..d5f595bd --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app/api + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8088"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 00000000..00cab1f4 --- /dev/null +++ b/api/README.md @@ -0,0 +1,17 @@ +# Browser REST API + +REST-обертка над `browser-use` RPC (`http://browser:8787/run`). + +## Endpoints + +- `GET /health` +- `POST /api/browser/tasks` +- `GET /api/browser/tasks/{task_id}` +- `GET /api/browser/tasks/{task_id}/result` + +## Пример + +```bash +curl -sS -X POST http://localhost:8088/api/browser/tasks \ + -H "Content-Type: application/json" \ + -d '{"task":"Открой example.com и верни заголовок страницы","timeout":300}' diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/browser_rpc_client.py b/api/browser_rpc_client.py new file mode 100644 index 00000000..9ce193b1 --- /dev/null +++ b/api/browser_rpc_client.py @@ -0,0 +1,31 @@ +from typing import Any + +import httpx + + +class BrowserRpcError(RuntimeError): ... + + +async def run_browser_task(rpc_url: str, task: str, timeout_sec: float) -> dict[str, Any]: + payload = {"task": task} + + timeout = httpx.Timeout(timeout_sec) + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post(rpc_url, json=payload) + except httpx.HTTPError as exc: + raise BrowserRpcError(f"Transport error: {exc}") + + if response.status_code >= 400: + body = response.text + raise BrowserRpcError(f"RPC HTTP: {response.status_code}: {body}") + + try: + data = response.json() + except ValueError as exc: + raise BrowserRpcError("RPC returned non-JSON response") + + if not isinstance(data, dict): + raise BrowserRpcError("RPC returned invalid payload type") + + return data diff --git a/api/config.py b/api/config.py new file mode 100644 index 00000000..43395310 --- /dev/null +++ b/api/config.py @@ -0,0 +1,16 @@ +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + app_host: str = os.getenv("BROWSER_API_HOST", "0.0.0.0") + app_port: int = os.getenv("BROWSER_API_PORT", "8080") + + browser_rpc_url: str = os.getenv("BROWSER_USE_RPC_URL", "http://browser:8787/run") + browser_rpc_timeout: float = float(os.getenv("BROWSER_USE_RPC_TIMEOUT", "900")) + + max_concurrency = int(os.getenv("BROWSER_API_MAX_CONCURRENCY", "2")) + + +settings = Settings() diff --git a/api/main.py b/api/main.py new file mode 100644 index 00000000..33b073a9 --- /dev/null +++ b/api/main.py @@ -0,0 +1,112 @@ +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse + +from api.browser_rpc_client import BrowserRpcError +from api.config import settings +from api.schemas import BrowserTaskRequest, BrowserTaskAcceptedResponse, BrowserTaskStatusResponse, \ + BrowserTaskResultResponse, TaskStatus +from api.task_store import TaskStore +from api.browser_rpc_client import run_browser_task + +store = TaskStore() +_semaphore = asyncio.Semaphore(settings.max_concurrency) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + + +app = FastAPI(title="Browser API", version="1.0.0", lifespan=lifespan) + + +async def _worker(task_id: str) -> None: + rec = await store.set_running(task_id) + if rec is None: + return None + + async with _semaphore: + try: + raw = await asyncio.wait_for( + run_browser_task( + rpc_url=settings.browser_rpc_url, + task=rec.task, + timeout_sec=float(rec.timeout), + ), + timeout=float(rec.timeout) + 5, + ) + success = bool(raw.get("success")) + await store.set_done(task_id=task_id, success=success, raw_response=raw, error=None) + except asyncio.TimeoutError: + await store.set_done( + task_id=task_id, + success=False, + raw_response=None, + error="Timeout exceeded", + ) + except BrowserRpcError as exc: + await store.set_done( + task_id=task_id, + success=False, + raw_response=None, + error=str(exc), + ) + except Exception as exc: + await store.set_done( + task_id=task_id, + success=False, + raw_response=None, + error=f"Internal error: {exc}", + ) + + +@app.get("/health") +async def health() -> dict: + return {"ok": True} + + +@app.post("/api/browser/tasks", response_model=BrowserTaskAcceptedResponse, status_code=202) +async def create_task(payload: BrowserTaskRequest) -> BrowserTaskAcceptedResponse: + rec = await store.create(task=payload.task.strip(), timeout=payload.timeout, metadata=payload.metadata) + asyncio.create_task(_worker(rec.task_id)) + return BrowserTaskAcceptedResponse(task_id=rec.task_id, status=rec.status) + + +@app.get("/api/browser/tasks/{task_id}", response_model=BrowserTaskStatusResponse) +async def get_task_status(task_id: str) -> BrowserTaskStatusResponse: + rec = await store.get(task_id=task_id) + if rec is None: + raise HTTPException(status_code=404, detail="Task not found") + return BrowserTaskStatusResponse(task_id=rec.task_id, status=rec.status, create_at=rec.create_at, + started_at=rec.started_at, finished_at=rec.finished_at, error=rec.error) + + +@app.get("/api/browser/tasks/{task_id}/result", response_model=BrowserTaskResultResponse) +async def get_task_result(task_id: str) -> BrowserTaskResultResponse: + rec = await store.get(task_id=task_id) + if rec is None: + raise HTTPException(status_code=404, detail="Task not found") + + if rec.status in (TaskStatus.queued, TaskStatus.running): + return JSONResponse(status_code=202, content={ + "task_id": rec.task_id, + "status": rec.status, + "success": False, + "execution_time": rec.execution_time, + "result": None, + "error": None, + "raw_response": None, + }) + + return BrowserTaskResultResponse( + task_id=rec.task_id, + status=rec.status, + success=(rec.status == "succeeded"), + execution_time=rec.execution_time, + result=rec.result, + error=rec.error, + raw_response=rec.raw_response, + ) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 00000000..66a15bb4 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.135.3 +uvicorn[standard]==0.44.0 +httpx==0.28.1 +pydantic==2.12.5 diff --git a/api/schemas.py b/api/schemas.py index f68f7ab6..9fe3acbe 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,15 +1,41 @@ -from typing import Optional +from enum import Enum +from typing import Optional, Any from pydantic import BaseModel, Field +class TaskStatus(str, Enum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + + class BrowserTaskRequest(BaseModel): task: str = Field(..., description="Задача для браузера") timeout: int = Field(300, description="Максимальное время выполнения задачи в секундах") + metadata: dict[str, Any] | None = Field(default=None, description="Метаданные клиента") -class BrowserTaskResponse(BaseModel): +class BrowserTaskAcceptedResponse(BaseModel): + task_id: str + status: TaskStatus + + +class BrowserTaskStatusResponse(BaseModel): + task_id: str + status: TaskStatus + create_at: float + started_at: float | None = None + finished_at: float | None = None + error: str | None = None + + +class BrowserTaskResultResponse(BaseModel): + task_id: str + status: TaskStatus success: bool - result: Optional[str] = None - error: Optional[str] = None execution_time: float + result: str | None = None + error: str | None = None + raw_response: dict[str, Any] | None = None diff --git a/api/task_store.py b/api/task_store.py new file mode 100644 index 00000000..7de5afc9 --- /dev/null +++ b/api/task_store.py @@ -0,0 +1,69 @@ +import time +import uuid +from asyncio import Lock +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from api.schemas import TaskStatus + + +@dataclass +class TaskRecord: + task_id: str + task: str + timeout: int + metadata: dict[str, Any] | None + status: TaskStatus = TaskStatus.queued + create_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + result: str | None = None + error: str | None = None + raw_response: dict[str, Any] | None = None + + @property + def execution_time(self) -> float: + if self.started_at is None: + return 0 + end = self.finished_at if self.finished_at is not None else time.time() + return max(0, end - self.started_at) + + +class TaskStore: + def __init__(self) -> None: + self._lock = Lock() + self._tasks: dict[str, TaskRecord] = {} + + async def create(self, task: str, timeout: int, metadata: dict[str, Any] | None) -> TaskRecord: + task_id = uuid.uuid4().hex + rec = TaskRecord(task_id=task_id, task=task, timeout=timeout, metadata=metadata) + async with self._lock: + self._tasks[task_id] = rec + return rec + + async def get(self, task_id: str) -> TaskRecord | None: + async with self._lock: + return self._tasks.get(task_id) + + async def set_running(self, task_id: str) -> TaskRecord | None: + async with self._lock: + rec = self._tasks.get(task_id) + if rec is None: + return None + rec.status = TaskStatus.running + rec.started_at = time.time() + return rec + + async def set_done(self, task_id: str, success: bool, raw_response: dict[str, Any] | None, + error: str | None) -> TaskRecord | None: + async with self._lock: + rec = self._tasks.get(task_id) + if rec is None: + return None + rec.finished_at = time.time() + rec.raw_response = raw_response + rec.error = error or (raw_response.get("error") if isinstance(raw_response, dict) else None) + rec.status = TaskStatus.succeeded if success else TaskStatus.failed + + return rec diff --git a/docker-compose.yml b/docker-compose.yml index de1baa52..5ec5a166 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: agent: - build: + build: context: ./hermes_code dockerfile: Dockerfile container_name: hermes-brain @@ -35,7 +35,6 @@ services: fi; exec python -m gateway.run " - browser: build: context: ./browser_env @@ -55,12 +54,31 @@ services: - browser_profiles:/src/browser_data restart: always healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9222/json/version >/dev/null && curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1"] + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:9222/json/version >/dev/null && curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1" ] interval: 10s timeout: 3s retries: 12 start_period: 20s + browser-api: + build: + context: ./api + dockerfile: Dockerfile + container_name: hermes-browser-api + environment: + - BROWSER_USE_RPC_URL=http://browser:8787/run + - BROWSER_API_HOST=0.0.0.0 + - BROWSER_API_PORT=8088 + - BROWSER_API_MAX_CONCURRENCY=2 + depends_on: + browser: + condition: service_healthy + ports: + - "8088:8088" + restart: always + networks: + - hermes-net + tunnel: image: cloudflare/cloudflared:latest profiles: