Compare commits
2 commits
365ab8dd79
...
ff1799cd98
| Author | SHA1 | Date | |
|---|---|---|---|
| ff1799cd98 | |||
| fb7ab50de6 |
7 changed files with 206 additions and 6 deletions
|
|
@ -11,6 +11,7 @@ REST API-обертка над `browser-use` RPC (`POST /run` в контейн
|
||||||
- `POST /api/browser/tasks` возвращает `202` и `task_id`
|
- `POST /api/browser/tasks` возвращает `202` и `task_id`
|
||||||
- `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...`
|
- `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...`
|
||||||
- `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена
|
- `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена
|
||||||
|
- `GET /api/browser/tasks/{task_id}/history` возвращает историю шагов агента
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
|
|
@ -102,6 +103,34 @@ Response `202`:
|
||||||
- `202` если задача еще `queued/running`
|
- `202` если задача еще `queued/running`
|
||||||
- `200` с финальным payload после завершения
|
- `200` с финальным payload после завершения
|
||||||
|
|
||||||
|
### `GET /api/browser/tasks/{task_id}/history`
|
||||||
|
|
||||||
|
- `202` если задача еще `queued/running`
|
||||||
|
- `200` с финальной историей шагов после завершения
|
||||||
|
|
||||||
|
Пример ответа `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "53f54fa4c1f24219b3949d56b0457875",
|
||||||
|
"status": "succeeded",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"kind": "thought",
|
||||||
|
"content": "Open target page",
|
||||||
|
"data": {"value": "Open target page"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"kind": "action",
|
||||||
|
"content": "Click login",
|
||||||
|
"data": {"value": "Click login"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Быстрый end-to-end пример
|
## Быстрый end-to-end пример
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
|
@ -117,4 +146,5 @@ TASK_ID=$(python -c "import json,sys;print(json.loads(sys.argv[1])['task_id'])"
|
||||||
|
|
||||||
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID"
|
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID"
|
||||||
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/result"
|
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/result"
|
||||||
|
curl -sS "http://localhost:8088/api/browser/tasks/$TASK_ID/history"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,21 @@ class BrowserTaskResultResponse(BaseModel):
|
||||||
result: str | None = Field(default=None, description="Итоговый текстовый результат")
|
result: str | None = Field(default=None, description="Итоговый текстовый результат")
|
||||||
error: str | None = Field(default=None, description="Текст ошибки, если выполнение не удалось")
|
error: str | None = Field(default=None, description="Текст ошибки, если выполнение не удалось")
|
||||||
raw_response: dict[str, Any] | None = Field(default=None, description="Сырой ответ от browser-use RPC")
|
raw_response: dict[str, Any] | None = Field(default=None, description="Сырой ответ от browser-use RPC")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskHistoryEvent(BaseModel):
|
||||||
|
"""Одно действие/шаг в истории выполнения browser-use агента."""
|
||||||
|
|
||||||
|
step: int = Field(..., description="Порядковый номер события в истории")
|
||||||
|
kind: str = Field(..., description="Тип события (thought/action/error/system)")
|
||||||
|
content: str | None = Field(default=None, description="Краткое текстовое описание события")
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict, description="Дополнительные структурированные данные")
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTaskHistoryResponse(BaseModel):
|
||||||
|
"""История действий агента для конкретной задачи."""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
|
history: list[TaskHistoryEvent] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class TaskRecord:
|
||||||
result: str | None = None
|
result: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
raw_response: dict[str, Any] | None = None
|
raw_response: dict[str, Any] | None = None
|
||||||
|
history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def execution_time(self) -> float:
|
def execution_time(self) -> float:
|
||||||
|
|
@ -61,6 +62,7 @@ class TaskStore:
|
||||||
raw_response: dict[str, Any] | None,
|
raw_response: dict[str, Any] | None,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
|
history: list[dict[str, Any]] | None = None,
|
||||||
) -> TaskRecord | None:
|
) -> TaskRecord | None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
rec = self._tasks.get(task_id)
|
rec = self._tasks.get(task_id)
|
||||||
|
|
@ -72,5 +74,6 @@ class TaskStore:
|
||||||
raw_response.get("error") if isinstance(raw_response, dict) else None)
|
raw_response.get("error") if isinstance(raw_response, dict) else None)
|
||||||
rec.result = result if result is not None else (
|
rec.result = result if result is not None else (
|
||||||
raw_response.get("result") if isinstance(raw_response, dict) else None)
|
raw_response.get("result") if isinstance(raw_response, dict) else None)
|
||||||
|
rec.history = list(history or [])
|
||||||
rec.status = TaskStatus.succeeded if success else TaskStatus.failed
|
rec.status = TaskStatus.succeeded if success else TaskStatus.failed
|
||||||
return rec
|
return rec
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from api.contracts.task_schemas import (
|
from api.contracts.task_schemas import (
|
||||||
BrowserTaskAcceptedResponse,
|
BrowserTaskAcceptedResponse,
|
||||||
|
BrowserTaskHistoryResponse,
|
||||||
BrowserTaskRequest,
|
BrowserTaskRequest,
|
||||||
BrowserTaskResultResponse,
|
BrowserTaskResultResponse,
|
||||||
BrowserTaskStatusResponse,
|
BrowserTaskStatusResponse,
|
||||||
|
TaskHistoryEvent,
|
||||||
)
|
)
|
||||||
from api.domain.task_status import TaskStatus
|
from api.domain.task_status import TaskStatus
|
||||||
from api.repositories.task_store import TaskRecord
|
from api.repositories.task_store import TaskRecord
|
||||||
|
|
@ -69,6 +71,32 @@ async def get_task_result(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/history", response_model=BrowserTaskHistoryResponse)
|
||||||
|
async def get_task_history(
|
||||||
|
task_id: str,
|
||||||
|
service: TaskService = Depends(get_task_service),
|
||||||
|
) -> JSONResponse | BrowserTaskHistoryResponse:
|
||||||
|
rec = await service.get_task(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.value,
|
||||||
|
"history": rec.history,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return BrowserTaskHistoryResponse(
|
||||||
|
task_id=rec.task_id,
|
||||||
|
status=rec.status,
|
||||||
|
history=_to_history_events(rec),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
|
def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
|
||||||
return BrowserTaskStatusResponse(
|
return BrowserTaskStatusResponse(
|
||||||
task_id=rec.task_id,
|
task_id=rec.task_id,
|
||||||
|
|
@ -78,3 +106,24 @@ def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
|
||||||
finished_at=rec.finished_at,
|
finished_at=rec.finished_at,
|
||||||
error=rec.error,
|
error=rec.error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_history_events(rec: TaskRecord) -> list[TaskHistoryEvent]:
|
||||||
|
events: list[TaskHistoryEvent] = []
|
||||||
|
for index, item in enumerate(rec.history, start=1):
|
||||||
|
kind = str(item.get("kind") or item.get("type") or "system")
|
||||||
|
content = item.get("content")
|
||||||
|
if content is not None:
|
||||||
|
content = str(content)
|
||||||
|
data = item.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
step = item.get("step")
|
||||||
|
if not isinstance(step, int):
|
||||||
|
step = index
|
||||||
|
|
||||||
|
events.append(TaskHistoryEvent(step=step, kind=kind, content=content, data=data))
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ class TaskService:
|
||||||
raw_response=raw,
|
raw_response=raw,
|
||||||
error=None,
|
error=None,
|
||||||
result=raw.get("result") if isinstance(raw, dict) else None,
|
result=raw.get("result") if isinstance(raw, dict) else None,
|
||||||
|
history=self._extract_history(raw),
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await self._store.set_done(
|
await self._store.set_done(
|
||||||
|
|
@ -66,6 +67,7 @@ class TaskService:
|
||||||
success=False,
|
success=False,
|
||||||
raw_response=None,
|
raw_response=None,
|
||||||
error="Timeout exceeded",
|
error="Timeout exceeded",
|
||||||
|
history=None,
|
||||||
)
|
)
|
||||||
except BrowserRpcError as exc:
|
except BrowserRpcError as exc:
|
||||||
await self._store.set_done(
|
await self._store.set_done(
|
||||||
|
|
@ -73,6 +75,7 @@ class TaskService:
|
||||||
success=False,
|
success=False,
|
||||||
raw_response=None,
|
raw_response=None,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
|
history=None,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._store.set_done(
|
await self._store.set_done(
|
||||||
|
|
@ -80,4 +83,21 @@ class TaskService:
|
||||||
success=False,
|
success=False,
|
||||||
raw_response=None,
|
raw_response=None,
|
||||||
error=f"Internal error: {exc}",
|
error=f"Internal error: {exc}",
|
||||||
|
history=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_history(raw: dict | None) -> list[dict]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = raw.get("history")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[dict] = []
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, dict):
|
||||||
|
normalized.append(event)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Файл: assets/config.example.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"browser": {
|
"browser": {
|
||||||
"headless": true,
|
"headless": true,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
|
|
||||||
from browser_use import Agent, Browser, ChatOpenAI
|
from browser_use import Agent, Browser, ChatOpenAI
|
||||||
|
|
@ -36,6 +37,7 @@ async def run_browser_task(task):
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"result": history.final_result(),
|
"result": history.final_result(),
|
||||||
|
"history": _extract_history_events(history),
|
||||||
"browser_view": browser_view_url,
|
"browser_view": browser_view_url,
|
||||||
}
|
}
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
|
@ -47,6 +49,90 @@ async def run_browser_task(task):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jsonable(value: Any) -> Any:
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _to_jsonable(val) for key, val in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [_to_jsonable(item) for item in value]
|
||||||
|
|
||||||
|
for method_name in ("model_dump", "dict", "to_dict"):
|
||||||
|
method = getattr(value, method_name, None)
|
||||||
|
if callable(method):
|
||||||
|
try:
|
||||||
|
dumped = method()
|
||||||
|
return _to_jsonable(dumped)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_history_items(history: Any, attr_name: str) -> list[Any]:
|
||||||
|
method = getattr(history, attr_name, None)
|
||||||
|
if not callable(method):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw: Any = method()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return raw
|
||||||
|
if isinstance(raw, (str, bytes, dict)):
|
||||||
|
return [raw]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return list(raw)
|
||||||
|
except TypeError:
|
||||||
|
return [raw]
|
||||||
|
except Exception:
|
||||||
|
return [raw]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_history_events(history: Any) -> list[dict[str, Any]]:
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def append_many(kind: str, items: list[Any]) -> None:
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
for item in items:
|
||||||
|
normalized = _to_jsonable(item)
|
||||||
|
payload = normalized if isinstance(normalized, dict) else {"value": normalized}
|
||||||
|
content = normalized if isinstance(normalized, str) else json.dumps(normalized, ensure_ascii=False)
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"step": len(events) + 1,
|
||||||
|
"kind": kind,
|
||||||
|
"content": content,
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
append_many("thought", _call_history_items(history, "model_thoughts"))
|
||||||
|
append_many("action", _call_history_items(history, "model_actions"))
|
||||||
|
append_many("error", _call_history_items(history, "errors"))
|
||||||
|
|
||||||
|
if events:
|
||||||
|
return events
|
||||||
|
|
||||||
|
fallback = _to_jsonable(history)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"kind": "system",
|
||||||
|
"content": fallback if isinstance(fallback, str) else json.dumps(fallback, ensure_ascii=False),
|
||||||
|
"data": fallback if isinstance(fallback, dict) else {"value": fallback},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class BrowserUseRPCHandler(BaseHTTPRequestHandler):
|
class BrowserUseRPCHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path != "/health":
|
if self.path != "/health":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue