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`
|
||||
- `GET /api/browser/tasks/{task_id}` возвращает `queued/running/...`
|
||||
- `GET /api/browser/tasks/{task_id}/result` возвращает `202`, пока задача не завершена
|
||||
- `GET /api/browser/tasks/{task_id}/history` возвращает историю шагов агента
|
||||
|
||||
## Архитектура
|
||||
|
||||
|
|
@ -102,6 +103,34 @@ Response `202`:
|
|||
- `202` если задача еще `queued/running`
|
||||
- `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 пример
|
||||
|
||||
```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/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="Итоговый текстовый результат")
|
||||
error: str | None = Field(default=None, description="Текст ошибки, если выполнение не удалось")
|
||||
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
|
||||
error: str | None = None
|
||||
raw_response: dict[str, Any] | None = None
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def execution_time(self) -> float:
|
||||
|
|
@ -61,6 +62,7 @@ class TaskStore:
|
|||
raw_response: dict[str, Any] | None,
|
||||
error: str | None,
|
||||
result: str | None = None,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
) -> TaskRecord | None:
|
||||
async with self._lock:
|
||||
rec = self._tasks.get(task_id)
|
||||
|
|
@ -72,5 +74,6 @@ class TaskStore:
|
|||
raw_response.get("error") if isinstance(raw_response, dict) else None)
|
||||
rec.result = result if result is not None else (
|
||||
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
|
||||
return rec
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from fastapi.responses import JSONResponse
|
|||
|
||||
from api.contracts.task_schemas import (
|
||||
BrowserTaskAcceptedResponse,
|
||||
BrowserTaskHistoryResponse,
|
||||
BrowserTaskRequest,
|
||||
BrowserTaskResultResponse,
|
||||
BrowserTaskStatusResponse,
|
||||
TaskHistoryEvent,
|
||||
)
|
||||
from api.domain.task_status import TaskStatus
|
||||
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:
|
||||
return BrowserTaskStatusResponse(
|
||||
task_id=rec.task_id,
|
||||
|
|
@ -78,3 +106,24 @@ def _to_status_response(rec: TaskRecord) -> BrowserTaskStatusResponse:
|
|||
finished_at=rec.finished_at,
|
||||
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,
|
||||
error=None,
|
||||
result=raw.get("result") if isinstance(raw, dict) else None,
|
||||
history=self._extract_history(raw),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._store.set_done(
|
||||
|
|
@ -66,6 +67,7 @@ class TaskService:
|
|||
success=False,
|
||||
raw_response=None,
|
||||
error="Timeout exceeded",
|
||||
history=None,
|
||||
)
|
||||
except BrowserRpcError as exc:
|
||||
await self._store.set_done(
|
||||
|
|
@ -73,6 +75,7 @@ class TaskService:
|
|||
success=False,
|
||||
raw_response=None,
|
||||
error=str(exc),
|
||||
history=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._store.set_done(
|
||||
|
|
@ -80,4 +83,21 @@ class TaskService:
|
|||
success=False,
|
||||
raw_response=None,
|
||||
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": {
|
||||
"headless": true,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from browser_use import Agent, Browser, ChatOpenAI
|
||||
|
|
@ -36,6 +37,7 @@ async def run_browser_task(task):
|
|||
return {
|
||||
"success": True,
|
||||
"result": history.final_result(),
|
||||
"history": _extract_history_events(history),
|
||||
"browser_view": browser_view_url,
|
||||
}
|
||||
except Exception as err:
|
||||
|
|
@ -47,6 +49,90 @@ async def run_browser_task(task):
|
|||
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):
|
||||
def do_GET(self):
|
||||
if self.path != "/health":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue