This commit is contained in:
-225
@@ -1,225 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from driver.registry import build_driver_factory
|
||||
from pydantic import BaseModel, Field
|
||||
from runtime.task import TaskRunner
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
|
||||
|
||||
class RegisterDeviceRequest(BaseModel):
|
||||
driver_type: str
|
||||
connection_info: dict[str, Any] = Field(default_factory=dict)
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class RuntimeConfigRequest(BaseModel):
|
||||
max_steps: int
|
||||
|
||||
|
||||
class ConsoleService:
|
||||
"""API-local typed operations shared by JSON and HTML console routers.
|
||||
|
||||
Stays in the ``api`` layer: it only depends on the existing injected
|
||||
Runtime state (device manager, metadata store, timeline, config store,
|
||||
task runner) and does not introduce HTTP/UI concerns below this layer.
|
||||
Page and JSON handlers receive the same instance so validation, driver
|
||||
allow-listing, persistence, and error mapping cannot drift apart.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
device_manager: DeviceManager,
|
||||
metadata_store: TaskMetadataStore,
|
||||
timeline: Timeline,
|
||||
config_store: DeviceConfigStore,
|
||||
task_runner: TaskRunner,
|
||||
) -> None:
|
||||
self._device_manager = device_manager
|
||||
self._metadata_store = metadata_store
|
||||
self._timeline = timeline
|
||||
self._config_store = config_store
|
||||
self._task_runner = task_runner
|
||||
|
||||
def list_devices(self) -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in self._device_manager.list_devices()]
|
||||
|
||||
def register_device(
|
||||
self,
|
||||
*,
|
||||
driver_type: str,
|
||||
connection_info: dict[str, Any],
|
||||
name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
driver_factory = build_driver_factory(driver_type, connection_info)
|
||||
device_id = uuid4().hex
|
||||
device = self._device_manager.register_device(
|
||||
device_id,
|
||||
driver_factory,
|
||||
name=name,
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
)
|
||||
self._config_store.add(
|
||||
device_id=device_id,
|
||||
name=name,
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
)
|
||||
return device.to_dict()
|
||||
|
||||
def unregister_device(self, device_id: str) -> None:
|
||||
if not self._has_device(device_id):
|
||||
raise KeyError("device not found")
|
||||
self._device_manager.unregister_device(device_id)
|
||||
self._config_store.remove(device_id)
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
*,
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = self._metadata_store.list_tasks()
|
||||
if device_id is not None:
|
||||
rows = [row for row in rows if row["device_id"] == device_id]
|
||||
if status is not None:
|
||||
rows = [row for row in rows if row["status"] == status]
|
||||
return rows
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
task = self._metadata_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise KeyError("task not found")
|
||||
return task
|
||||
|
||||
def get_task_timeline(self, task_id: str) -> list[dict[str, Any]]:
|
||||
if self._metadata_store.get_task(task_id) is None:
|
||||
raise KeyError("task not found")
|
||||
return [
|
||||
self._inline_screenshots(record) for record in self._timeline.read(task_id)
|
||||
]
|
||||
|
||||
def get_runtime_config(self) -> dict[str, int]:
|
||||
return {"max_steps": self._runner_max_steps()}
|
||||
|
||||
def update_runtime_config(self, *, max_steps: int) -> dict[str, int]:
|
||||
if max_steps <= 0:
|
||||
raise ValueError("max_steps must be positive")
|
||||
self._set_runner_max_steps(max_steps)
|
||||
self._config_store.set_setting("max_steps", max_steps)
|
||||
return {"max_steps": max_steps}
|
||||
|
||||
def _has_device(self, device_id: str) -> bool:
|
||||
return any(
|
||||
device.id == device_id for device in self._device_manager.list_devices()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inline_screenshots(record: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(record)
|
||||
for path_key, image_key in (
|
||||
("before_screenshot_path", "before_image_base64"),
|
||||
("after_screenshot_path", "after_image_base64"),
|
||||
("screenshot_path", "image_base64"),
|
||||
):
|
||||
screenshot_path = payload.get(path_key)
|
||||
if not screenshot_path:
|
||||
continue
|
||||
path = Path(str(screenshot_path))
|
||||
if path.exists():
|
||||
payload[image_key] = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
if "after_image_base64" not in payload and "image_base64" in payload:
|
||||
payload["after_image_base64"] = payload["image_base64"]
|
||||
elif "after_image_base64" in payload:
|
||||
payload["image_base64"] = payload["after_image_base64"]
|
||||
return payload
|
||||
|
||||
def _runner_max_steps(self) -> int:
|
||||
config = getattr(self._task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
return int(config.max_steps)
|
||||
|
||||
def _set_runner_max_steps(self, max_steps: int) -> None:
|
||||
config = getattr(self._task_runner, "config", None)
|
||||
if config is None or not hasattr(config, "max_steps"):
|
||||
raise RuntimeError("task runner config unavailable")
|
||||
config.max_steps = max_steps
|
||||
|
||||
|
||||
def create_console_router(service: ConsoleService) -> Any:
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
router = APIRouter(prefix="/console", tags=["console"])
|
||||
|
||||
@router.get("/devices")
|
||||
def devices() -> list[dict[str, Any]]:
|
||||
return service.list_devices()
|
||||
|
||||
@router.post("/devices", status_code=status.HTTP_201_CREATED)
|
||||
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return service.register_device(
|
||||
driver_type=request.driver_type,
|
||||
connection_info=request.connection_info,
|
||||
name=request.name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.delete(
|
||||
"/devices/{device_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def unregister_device(device_id: str) -> Response:
|
||||
try:
|
||||
service.unregister_device(device_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@router.get("/tasks")
|
||||
def tasks(
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return service.list_tasks(device_id=device_id, status=status)
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def task_detail(task_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return service.get_task(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/tasks/{task_id}/timeline")
|
||||
def task_timeline(task_id: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return service.get_task_timeline(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/config")
|
||||
def runtime_config() -> dict[str, int]:
|
||||
return service.get_runtime_config()
|
||||
|
||||
@router.put("/config")
|
||||
def update_runtime_config(
|
||||
request: RuntimeConfigRequest,
|
||||
) -> dict[str, int]:
|
||||
try:
|
||||
return service.update_runtime_config(max_steps=request.max_steps)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return router
|
||||
@@ -1,360 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.console import ConsoleService
|
||||
from driver.registry import SUPPORTED_DRIVER_TYPES
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).parent / "templates" / "runtime_console"
|
||||
_STATIC_DIR = Path(__file__).parent / "static" / "runtime_console"
|
||||
|
||||
_ENV = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
auto_reload=False,
|
||||
)
|
||||
|
||||
_TASK_STATUSES = ["created", "running", "completed", "failed", "cancelled"]
|
||||
_DEFAULT_FORM_VALUES: dict[str, str] = {
|
||||
"name": "",
|
||||
"driver_type": "",
|
||||
"server_url": "",
|
||||
"udid": "",
|
||||
"wda_local_port": "",
|
||||
}
|
||||
|
||||
|
||||
def _render(request: Request, template_name: str, **context: Any) -> str:
|
||||
return _ENV.get_template(template_name).render(
|
||||
url_for=request.url_for,
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
def _device_names(service: ConsoleService) -> dict[str, str]:
|
||||
return {
|
||||
device["id"]: device.get("name") or device["id"]
|
||||
for device in service.list_devices()
|
||||
}
|
||||
|
||||
|
||||
def _default_driver_type() -> str:
|
||||
if not _DEFAULT_FORM_VALUES["driver_type"] and SUPPORTED_DRIVER_TYPES:
|
||||
_DEFAULT_FORM_VALUES["driver_type"] = next(iter(SUPPORTED_DRIVER_TYPES))
|
||||
return _DEFAULT_FORM_VALUES["driver_type"]
|
||||
|
||||
|
||||
def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw_results = record.get("ocr_results")
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
return [result for result in raw_results if isinstance(result, dict)]
|
||||
|
||||
|
||||
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
tool_call = record.get("tool_call")
|
||||
if not isinstance(tool_call, dict):
|
||||
return []
|
||||
if tool_call.get("action") not in {"get_ui_tree", "ui_tree"}:
|
||||
return []
|
||||
step_result = record.get("result")
|
||||
if not isinstance(step_result, dict):
|
||||
return []
|
||||
raw_nodes = step_result.get("result")
|
||||
if not isinstance(raw_nodes, list):
|
||||
return []
|
||||
return [node for node in raw_nodes if isinstance(node, dict)]
|
||||
|
||||
|
||||
def _config_context(
|
||||
request: Request,
|
||||
service: ConsoleService,
|
||||
*,
|
||||
form_values: dict[str, str] | None = None,
|
||||
device_error: str = "",
|
||||
device_added: str = "",
|
||||
config_error: str = "",
|
||||
config_saved: bool = False,
|
||||
max_steps_override: int | None = None,
|
||||
) -> str:
|
||||
form = dict(_DEFAULT_FORM_VALUES)
|
||||
form["driver_type"] = _default_driver_type()
|
||||
if form_values:
|
||||
form.update(form_values)
|
||||
config = service.get_runtime_config()
|
||||
return _render(
|
||||
request,
|
||||
"config.html",
|
||||
active_section="config",
|
||||
devices=service.list_devices(),
|
||||
max_steps=max_steps_override
|
||||
if max_steps_override is not None
|
||||
else config["max_steps"],
|
||||
supported_driver_types=list(SUPPORTED_DRIVER_TYPES),
|
||||
form_values=form,
|
||||
device_error=device_error,
|
||||
device_added=device_added,
|
||||
config_error=config_error,
|
||||
config_saved=config_saved,
|
||||
)
|
||||
|
||||
|
||||
def create_console_web_router(service: ConsoleService) -> APIRouter:
|
||||
router = APIRouter(prefix="/ui", tags=["console-ui"])
|
||||
|
||||
@router.get("/", name="runtime_console_dashboard", include_in_schema=False)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
devices = service.list_devices()
|
||||
tasks = service.list_tasks()
|
||||
running = sum(1 for task in tasks if task.get("status") == "running")
|
||||
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
active_section="dashboard",
|
||||
devices=devices,
|
||||
device_count=len(devices),
|
||||
task_count=len(tasks),
|
||||
running_tasks=running,
|
||||
failed_tasks=failed,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/_status_fragment",
|
||||
name="runtime_console_status_fragment",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def status_fragment(request: Request) -> HTMLResponse:
|
||||
devices = service.list_devices()
|
||||
tasks = service.list_tasks()
|
||||
running = sum(1 for task in tasks if task.get("status") == "running")
|
||||
failed = sum(1 for task in tasks if task.get("status") == "failed")
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"_status_fragment.html",
|
||||
devices=devices,
|
||||
device_count=len(devices),
|
||||
running_tasks=running,
|
||||
failed_tasks=failed,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/tasks", name="runtime_console_tasks", include_in_schema=False)
|
||||
def tasks(
|
||||
request: Request,
|
||||
device_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
service_tasks = service.list_tasks(device_id=device_id, status=status)
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"tasks.html",
|
||||
active_section="tasks",
|
||||
tasks=service_tasks,
|
||||
devices=service.list_devices(),
|
||||
device_names=_device_names(service),
|
||||
task_statuses=_TASK_STATUSES,
|
||||
selected_device_id=device_id or "",
|
||||
selected_status=status or "",
|
||||
)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}",
|
||||
name="runtime_console_task_detail",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def task_detail(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
step: int | None = None,
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
task = service.get_task(task_id)
|
||||
timeline = service.get_task_timeline(task_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if timeline:
|
||||
index = 0
|
||||
if step is not None:
|
||||
index = max(0, min(step, len(timeline) - 1))
|
||||
current_step = timeline[index]
|
||||
else:
|
||||
index = 0
|
||||
current_step = None
|
||||
device_name = _device_names(service).get(
|
||||
task.get("device_id", ""),
|
||||
task.get("device_id", ""),
|
||||
)
|
||||
return HTMLResponse(
|
||||
_render(
|
||||
request,
|
||||
"task_detail.html",
|
||||
active_section="tasks",
|
||||
task=task,
|
||||
device_name=device_name,
|
||||
timeline=timeline,
|
||||
current_step=current_step if current_step is not None else {},
|
||||
current_step_index=index,
|
||||
ocr_results=_ocr_results(current_step)
|
||||
if current_step is not None
|
||||
else [],
|
||||
ui_tree_nodes=_ui_tree_nodes(current_step)
|
||||
if current_step is not None
|
||||
else [],
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/config", name="runtime_console_config", include_in_schema=False)
|
||||
def config(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(_config_context(request, service))
|
||||
|
||||
@router.post(
|
||||
"/config/devices",
|
||||
name="runtime_console_register_device",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def register_device(request: Request) -> HTMLResponse | RedirectResponse:
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()
|
||||
driver_type = (form.get("driver_type") or "").strip()
|
||||
server_url = (form.get("server_url") or "").strip()
|
||||
udid = (form.get("udid") or "").strip()
|
||||
wda_local_port_raw = (form.get("wda_local_port") or "").strip()
|
||||
|
||||
connection_info: dict[str, Any] = {}
|
||||
if server_url:
|
||||
connection_info["server_url"] = server_url
|
||||
if udid:
|
||||
connection_info["udid"] = udid
|
||||
if wda_local_port_raw:
|
||||
try:
|
||||
connection_info["wda_local_port"] = int(wda_local_port_raw)
|
||||
except ValueError:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
form_values={
|
||||
"name": name,
|
||||
"driver_type": driver_type,
|
||||
"server_url": server_url,
|
||||
"udid": udid,
|
||||
"wda_local_port": wda_local_port_raw,
|
||||
},
|
||||
device_error="wda_local_port must be a number",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
service.register_device(
|
||||
driver_type=driver_type,
|
||||
connection_info=connection_info,
|
||||
name=name or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
form_values={
|
||||
"name": name,
|
||||
"driver_type": driver_type,
|
||||
"server_url": server_url,
|
||||
"udid": udid,
|
||||
"wda_local_port": wda_local_port_raw,
|
||||
},
|
||||
device_error=str(exc),
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/config/devices/{device_id}/delete",
|
||||
name="runtime_console_remove_device",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def remove_device(
|
||||
request: Request,
|
||||
device_id: str,
|
||||
) -> HTMLResponse | RedirectResponse:
|
||||
try:
|
||||
service.unregister_device(device_id)
|
||||
except KeyError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(request, service, device_error=str(exc)),
|
||||
status_code=404,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/config/max-steps",
|
||||
name="runtime_console_update_max_steps",
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def update_max_steps(request: Request) -> HTMLResponse | RedirectResponse:
|
||||
form = await request.form()
|
||||
raw = (form.get("max_steps") or "").strip()
|
||||
try:
|
||||
max_steps = int(raw)
|
||||
except ValueError:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
config_error="max_steps must be an integer",
|
||||
max_steps_override=raw if raw else None,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
service.update_runtime_config(max_steps=max_steps)
|
||||
except ValueError as exc:
|
||||
return HTMLResponse(
|
||||
_config_context(
|
||||
request,
|
||||
service,
|
||||
config_error=str(exc),
|
||||
max_steps_override=max_steps,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(
|
||||
url=request.url_for("runtime_console_config"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def mount_console_assets(app: Any) -> None:
|
||||
"""Mount the package-owned static assets under ``/ui/assets``.
|
||||
|
||||
Kept as a separate helper so ``api.rest`` can mount assets with a stable
|
||||
route name that templates reference via ``url_for``.
|
||||
"""
|
||||
app.mount(
|
||||
"/ui/assets",
|
||||
StaticFiles(directory=str(_STATIC_DIR)),
|
||||
name="runtime_console_assets",
|
||||
)
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from api.console import ConsoleService, create_console_router
|
||||
from api.console_web import create_console_web_router, mount_console_assets
|
||||
from api.errors import semantic_error
|
||||
from core.models import Task
|
||||
from device.manager import DEFAULT_MANAGER, DeviceManager
|
||||
from driver.registry import build_driver_factory
|
||||
from runtime.executor import Executor, default_tool_registry
|
||||
from runtime.task import TaskRunner, TaskRunnerConfig
|
||||
from storage.device_config import DEFAULT_MAX_STEPS, DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
from tools.launch_app import launch_app
|
||||
from tools.screenshot import take_screenshot
|
||||
from tools.tap import tap
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
manager: DeviceManager | None = None,
|
||||
task_runner: TaskRunner | None = None,
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
device_config_store: DeviceConfigStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
) -> Any:
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
device_manager = manager or DEFAULT_MANAGER
|
||||
store = metadata_store or TaskMetadataStore()
|
||||
config_store = device_config_store or DeviceConfigStore()
|
||||
timeline_store = timeline or Timeline()
|
||||
max_steps = _load_max_steps(config_store)
|
||||
_reload_device_configs(device_manager, config_store)
|
||||
runner = task_runner or TaskRunner(
|
||||
metadata_store=store,
|
||||
executor=Executor(tools=default_tool_registry(manager=device_manager)),
|
||||
timeline=timeline_store,
|
||||
config=TaskRunnerConfig(max_steps=max_steps),
|
||||
)
|
||||
_apply_max_steps(runner, max_steps)
|
||||
app = FastAPI(title="Apex Agent API")
|
||||
|
||||
class TapRequest(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
class ScreenshotResponse(BaseModel):
|
||||
image_base64: str
|
||||
mime_type: str = "image/png"
|
||||
|
||||
class LaunchRequest(BaseModel):
|
||||
app_id: str
|
||||
|
||||
class AgentTaskRequest(BaseModel):
|
||||
goal: str
|
||||
device_id: str
|
||||
|
||||
console_service = ConsoleService(
|
||||
device_manager=device_manager,
|
||||
metadata_store=store,
|
||||
timeline=timeline_store,
|
||||
config_store=config_store,
|
||||
task_runner=runner,
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
@app.get("/devices")
|
||||
def devices() -> list[dict[str, Any]]:
|
||||
return [device.to_dict() for device in device_manager.list_devices()]
|
||||
|
||||
@app.post("/devices/{device_id}/tap")
|
||||
def tap_device(device_id: str, request: TapRequest) -> dict[str, Any]:
|
||||
return _raise_semantic(
|
||||
lambda: tap(
|
||||
request.x,
|
||||
request.y,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/devices/{device_id}/screenshot")
|
||||
def screenshot_device(device_id: str) -> dict[str, str]:
|
||||
import base64
|
||||
|
||||
image = _raise_semantic(
|
||||
lambda: take_screenshot(device_id, manager=device_manager)
|
||||
)
|
||||
response = ScreenshotResponse(
|
||||
image_base64=base64.b64encode(image).decode("ascii")
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
@app.post("/devices/{device_id}/launch")
|
||||
def launch_device(device_id: str, request: LaunchRequest) -> dict[str, Any]:
|
||||
return _raise_semantic(
|
||||
lambda: launch_app(
|
||||
request.app_id,
|
||||
device_id=device_id,
|
||||
manager=device_manager,
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/agent/task")
|
||||
def start_task(
|
||||
request: AgentTaskRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> dict[str, str]:
|
||||
task = Task(goal=request.goal, device_id=request.device_id)
|
||||
store.create_task(task)
|
||||
background_tasks.add_task(runner.run, task)
|
||||
return {"task_id": task.id, "status": task.status}
|
||||
|
||||
@app.get("/task/{task_id}")
|
||||
def get_task(task_id: str) -> dict[str, Any]:
|
||||
task = store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return task
|
||||
|
||||
app.include_router(create_console_router(console_service))
|
||||
app.include_router(create_console_web_router(console_service))
|
||||
mount_console_assets(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _raise_semantic(func: Any) -> Any:
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=semantic_error(exc)) from exc
|
||||
|
||||
|
||||
def _load_max_steps(config_store: DeviceConfigStore) -> int:
|
||||
raw_value = config_store.get_setting("max_steps")
|
||||
try:
|
||||
max_steps = int(raw_value) if raw_value is not None else DEFAULT_MAX_STEPS
|
||||
except ValueError:
|
||||
return DEFAULT_MAX_STEPS
|
||||
if max_steps <= 0:
|
||||
return DEFAULT_MAX_STEPS
|
||||
return max_steps
|
||||
|
||||
|
||||
def _apply_max_steps(task_runner: Any, max_steps: int) -> None:
|
||||
config = getattr(task_runner, "config", None)
|
||||
if config is not None and hasattr(config, "max_steps"):
|
||||
config.max_steps = max_steps
|
||||
|
||||
|
||||
def _reload_device_configs(
|
||||
device_manager: DeviceManager,
|
||||
config_store: DeviceConfigStore,
|
||||
) -> None:
|
||||
for config in config_store.list():
|
||||
device_manager.register_device(
|
||||
config["device_id"],
|
||||
build_driver_factory(config["driver_type"], config["connection_info"]),
|
||||
name=config["name"],
|
||||
driver_type=config["driver_type"],
|
||||
connection_info=config["connection_info"],
|
||||
)
|
||||
@@ -1,623 +0,0 @@
|
||||
:root {
|
||||
color: #202124;
|
||||
background: #f6f7f9;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
border-right: 1px solid #d9dde5;
|
||||
background: #ffffff;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #2f7c67;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.icon-text-button,
|
||||
.icon-button,
|
||||
.task-row {
|
||||
border: 1px solid #d4d9e2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.icon-text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
border-color: #2f7c67;
|
||||
background: #e7f4ef;
|
||||
color: #1f5f4e;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 4px 0 0;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.view-grid,
|
||||
.config-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.panel {
|
||||
border: 1px solid #d9dde5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
min-height: 170px;
|
||||
border: 1px dashed #c8ced8;
|
||||
border-radius: 8px;
|
||||
color: #667085;
|
||||
text-align: center;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
border: 1px solid #e2e6ec;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.device-row strong,
|
||||
.device-row span,
|
||||
.task-goal,
|
||||
.task-meta {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.device-row span {
|
||||
display: block;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.driver-label,
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.driver-label {
|
||||
background: #eef0f4;
|
||||
color: #444b56;
|
||||
}
|
||||
|
||||
.status-pill.idle,
|
||||
.status-pill.completed {
|
||||
background: #e5f4ec;
|
||||
color: #1f6b4a;
|
||||
}
|
||||
|
||||
.status-pill.busy,
|
||||
.status-pill.running {
|
||||
background: #e8f1fb;
|
||||
color: #275b8d;
|
||||
}
|
||||
|
||||
.status-pill.created,
|
||||
.status-pill.cancelled {
|
||||
background: #f0edf8;
|
||||
color: #67508f;
|
||||
}
|
||||
|
||||
.status-pill.offline,
|
||||
.status-pill.failed,
|
||||
.status-pill.error {
|
||||
background: #fdebea;
|
||||
color: #a43c37;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.form-grid,
|
||||
.settings-form,
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid #cbd2dc;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #202124;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.task-browser {
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 10px;
|
||||
margin-bottom: 8px;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
border-color: #2f7c67;
|
||||
box-shadow: 0 0 0 2px #d9efe8;
|
||||
}
|
||||
|
||||
.task-goal {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-row .status-pill {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.detail-grid div {
|
||||
border: 1px solid #e2e6ec;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
margin-bottom: 4px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-button.danger {
|
||||
color: #a43c37;
|
||||
}
|
||||
|
||||
.timeline-stage {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.evidence-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.evidence-pane h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.screenshot-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 280px;
|
||||
border: 1px solid #d9dde5;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 520px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.step-data {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-data h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.operation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.operation-grid div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.operation-grid dt {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.operation-grid dd {
|
||||
margin: 2px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ocr-results {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ocr-results li {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-left: 3px solid #2f7c67;
|
||||
background: #f5faf7;
|
||||
padding: 8px 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ocr-results span,
|
||||
.muted {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ui-tree {
|
||||
border: 1px solid #e2e6ec;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ui-tree summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.ui-tree-nodes {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ui-tree-nodes li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 3px 8px;
|
||||
border-left: 3px solid #5f7fb0;
|
||||
background: #f1f5fb;
|
||||
padding: 8px 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-tree-nodes code,
|
||||
.ui-tree-nodes li > span {
|
||||
grid-column: 1 / -1;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 248px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border: 1px solid #e2e6ec;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.managed {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
grid-template-columns: minmax(140px, 240px) auto;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 12px 0 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: #fdebea;
|
||||
color: #a43c37;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background: #e5f4ec;
|
||||
color: #1f6b4a;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.timeline-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.evidence-grid,
|
||||
.operation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Narrow server-rendered live-status enhancement. Fetches an HTML fragment
|
||||
// from the same origin and replaces only the dashboard live region. Does
|
||||
// not parse JSON, rebuild DOM from client state, or depend on any framework.
|
||||
(function () {
|
||||
"use strict";
|
||||
var region = document.getElementById("live-status");
|
||||
if (!region) {
|
||||
return;
|
||||
}
|
||||
setInterval(function () {
|
||||
fetch("/ui/_status_fragment", { headers: { Accept: "text/html" } })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("fragment refresh failed");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
region.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
// Ignore transient network errors; the next tick will retry.
|
||||
});
|
||||
}, 10000);
|
||||
})();
|
||||
@@ -1,40 +0,0 @@
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Devices</span>
|
||||
<strong>{{ device_count }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Running</span>
|
||||
<strong>{{ running_tasks }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Failed</span>
|
||||
<strong>{{ failed_tasks }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Status</h2>
|
||||
</div>
|
||||
{% if not devices %}
|
||||
<div class="empty-state">
|
||||
<span>No devices registered. Add one from Config.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="device-list">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="driver-label">{{ device.driver_type }}</span>
|
||||
<span class="status-pill {{ device.status }}">{{ device.status }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -1,46 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Apex Console{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('runtime_console_assets', path='console.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Console navigation">
|
||||
<div class="brand">
|
||||
<span class="brand-icon" aria-hidden="true">AM</span>
|
||||
<div>
|
||||
<strong>Apex Console</strong>
|
||||
<span>Runtime /ui</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list">
|
||||
<a
|
||||
class="nav-button{% if active_section == 'dashboard' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_dashboard') }}"
|
||||
>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'tasks' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_tasks') }}"
|
||||
>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-button{% if active_section == 'config' %} active{% endif %}"
|
||||
href="{{ url_for('runtime_console_config') }}"
|
||||
>
|
||||
<span>Config</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="workspace">
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,98 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Config · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Config</h1>
|
||||
<p>Register devices and tune Runtime parameters.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="config-layout">
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Device Configuration</h2>
|
||||
</div>
|
||||
{% if device_error %}
|
||||
<p class="alert error">{{ device_error }}</p>
|
||||
{% endif %}
|
||||
{% if device_added %}
|
||||
<p class="alert success">Device "{{ device_added }}" registered.</p>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{{ url_for('runtime_console_register_device') }}">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value="{{ form_values.name }}" placeholder="Desk iPhone" />
|
||||
</label>
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_type">
|
||||
{% for driver_type in supported_driver_types %}
|
||||
<option
|
||||
value="{{ driver_type }}"
|
||||
{% if driver_type == form_values.driver_type %}selected{% endif %}
|
||||
>{{ driver_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Server URL
|
||||
<input type="url" name="server_url" value="{{ form_values.server_url }}" />
|
||||
</label>
|
||||
<label>
|
||||
UDID
|
||||
<input type="text" name="udid" value="{{ form_values.udid }}" />
|
||||
</label>
|
||||
<label>
|
||||
WDA local port
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
name="wda_local_port"
|
||||
value="{{ form_values.wda_local_port }}"
|
||||
/>
|
||||
</label>
|
||||
<button class="icon-text-button submit-button" type="submit">
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul class="device-list managed">
|
||||
{% for device in devices %}
|
||||
<li class="device-row">
|
||||
<div>
|
||||
<strong>{{ device.name or device.id }}</strong>
|
||||
<span>{{ device.id }}</span>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('runtime_console_remove_device', device_id=device.id) }}">
|
||||
<button class="icon-button danger" type="submit" title="Remove device" aria-label="Remove device">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>Runtime Parameters</h2>
|
||||
</div>
|
||||
{% if config_error %}
|
||||
<p class="alert error">{{ config_error }}</p>
|
||||
{% endif %}
|
||||
{% if config_saved %}
|
||||
<p class="alert success">Saved.</p>
|
||||
{% endif %}
|
||||
<form class="settings-form" method="post" action="{{ url_for('runtime_console_update_max_steps') }}">
|
||||
<label>
|
||||
Max steps
|
||||
<input type="number" min="1" name="max_steps" value="{{ max_steps }}" />
|
||||
</label>
|
||||
<button class="icon-text-button" type="submit">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Devices · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>{{ device_count }} device(s) / {{ task_count }} task(s)</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="view-grid">
|
||||
<div id="live-status">
|
||||
{% include "_status_fragment.html" %}
|
||||
</div>
|
||||
</section>
|
||||
<script src="{{ url_for('runtime_console_assets', path='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,156 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Task {{ task.id }} · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Task Detail</h1>
|
||||
<p><a href="{{ url_for('runtime_console_tasks') }}">← Back to tasks</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="section-title">
|
||||
<h2>{{ task.goal }}</h2>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Task ID</dt>
|
||||
<dd>{{ task.id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Device</dt>
|
||||
<dd>{{ device_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ task.created_at or "-" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ task.updated_at or "-" }}</dd>
|
||||
</div>
|
||||
{% if task.failure_reason %}
|
||||
<div>
|
||||
<dt>Failure</dt>
|
||||
<dd>{{ task.failure_reason }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<span>Step {{ current_step_index }} of {{ timeline|length }}</span>
|
||||
</div>
|
||||
|
||||
{% if not timeline %}
|
||||
<div class="empty-state compact">
|
||||
<span>No timeline records captured.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="timeline-controls" method="get" action="{{ url_for('runtime_console_task_detail', task_id=task.id) }}">
|
||||
<label>
|
||||
Step
|
||||
<select name="step" onchange="this.form.submit()">
|
||||
{% for record in timeline %}
|
||||
<option
|
||||
value="{{ loop.index0 }}"
|
||||
{% if loop.index0 == current_step_index %}selected{% endif %}
|
||||
>Step {{ loop.index }}{% if record.tool_call and record.tool_call.get('action') %} ({{ record.tool_call.get('action') }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Show</span></button></noscript>
|
||||
</form>
|
||||
|
||||
<div class="timeline-stage">
|
||||
<div class="evidence-grid">
|
||||
<section class="evidence-pane">
|
||||
<h3>Before action</h3>
|
||||
<div class="screenshot-frame">
|
||||
{% if current_step.before_image_base64 %}
|
||||
<img
|
||||
src="data:image/png;base64,{{ current_step.before_image_base64 }}"
|
||||
alt="Screenshot captured before the action"
|
||||
/>
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="evidence-pane">
|
||||
<h3>After action</h3>
|
||||
<div class="screenshot-frame">
|
||||
{% if current_step.after_image_base64 %}
|
||||
<img
|
||||
src="data:image/png;base64,{{ current_step.after_image_base64 }}"
|
||||
alt="Screenshot captured after the action"
|
||||
/>
|
||||
{% else %}
|
||||
<span>No screenshot</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="step-data">
|
||||
<div>
|
||||
<h3>Operation</h3>
|
||||
<dl class="operation-grid">
|
||||
<div>
|
||||
<dt>Action</dt>
|
||||
<dd>{{ current_step.tool_call.get('action', '-') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ current_step.tool_call.get('description', '-') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<pre>{{ current_step.tool_call | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Execution result</h3>
|
||||
<pre>{{ current_step.result | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>OCR results</h3>
|
||||
{% if ocr_results %}
|
||||
<ul class="ocr-results">
|
||||
{% for ocr in ocr_results %}
|
||||
<li>
|
||||
<strong>{{ ocr.get('text') or '-' }}</strong>
|
||||
<span>{{ ocr.get('bounds') | tojson }}</span>
|
||||
{% if ocr.get('confidence') is not none %}
|
||||
<span>confidence {{ '%.3f' | format(ocr.get('confidence')) }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No OCR output captured for this step.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ui_tree_nodes %}
|
||||
<div>
|
||||
<h3>UI tree</h3>
|
||||
<details class="ui-tree" open>
|
||||
<summary>{{ ui_tree_nodes|length }} normalized nodes</summary>
|
||||
<ul class="ui-tree-nodes">
|
||||
{% for node in ui_tree_nodes %}
|
||||
<li>
|
||||
<strong>{{ node.get('type') or 'unknown' }}</strong>
|
||||
<span>{{ node.get('text') or node.get('id') or '-' }}</span>
|
||||
<code>{{ node.get('bounds') | tojson }}</code>
|
||||
{% if node.get('confidence') is not none %}
|
||||
<span>confidence {{ '%.3f' | format(node.get('confidence')) }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,62 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tasks · Apex Console{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Tasks</h1>
|
||||
<p>{{ tasks|length }} task(s) match the current filters</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel task-browser">
|
||||
<div class="section-title">
|
||||
<h2>Task List</h2>
|
||||
</div>
|
||||
<form class="filters" method="get" action="{{ url_for('runtime_console_tasks') }}">
|
||||
<label>
|
||||
Device
|
||||
<select name="device_id">
|
||||
<option value="">All devices</option>
|
||||
{% for device in devices %}
|
||||
<option
|
||||
value="{{ device.id }}"
|
||||
{% if device.id == selected_device_id %}selected{% endif %}
|
||||
>{{ device.name or device.id }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{% for status_name in task_statuses %}
|
||||
<option
|
||||
value="{{ status_name }}"
|
||||
{% if status_name == selected_status %}selected{% endif %}
|
||||
>{{ status_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button class="icon-text-button" type="submit"><span>Apply</span></button></noscript>
|
||||
</form>
|
||||
|
||||
{% if not tasks %}
|
||||
<div class="empty-state compact">
|
||||
<span>No tasks match the current filters.</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for task in tasks %}
|
||||
<a
|
||||
class="task-row"
|
||||
href="{{ url_for('runtime_console_task_detail', task_id=task.id) }}"
|
||||
>
|
||||
<span class="task-goal">{{ task.goal }}</span>
|
||||
<span class="task-meta">
|
||||
{{ device_names.get(task.device_id, task.device_id) }}
|
||||
</span>
|
||||
<span class="status-pill {{ task.status }}">{{ task.status }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user