Files
agentic-mobile-control/api/rest.py
T
q792602257andClaude Opus 4.6 c0a653fa3f feat(host-agent): serve built console via SPA-aware static mount
Add an optional single-process mode where the backend serves the built
console bundle itself, so operators don't need a separate `npm run dev`
for edge/dev setups. When RUNTIME_CONSOLE_STATIC_DIR points at the
console dist directory, the app mounts a SpaStaticFiles handler at /ui/
(with 404 fallback to index.html for client-side routing) and redirects
/ to /ui/. The console build uses an empty VITE_API_BASE_URL for relative
API paths (same-origin, no CORS), and Vite's base is set to /ui/ so
assets resolve under the mount. /console/* JSON API is unchanged and is
shared by both serve modes.

api.ts now treats an explicitly-empty VITE_API_BASE_URL as "use relative
paths" instead of falling back to the dev default, which previously
forced absolute URLs even in same-origin builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:29:58 +08:00

211 lines
6.8 KiB
Python

import os
from pathlib import Path
from typing import Any
from api.console import create_console_router
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.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
class SpaStaticFiles(StaticFiles):
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown
path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the
SPA's client-side router handle it.
"""
async def get_response(self, path: str, scope: Scope) -> Any:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404 and path != "index.html":
return await super().get_response("index.html", scope)
raise
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")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
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
@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(
device_manager=device_manager,
metadata_store=store,
timeline=timeline_store,
config_store=config_store,
task_runner=runner,
)
)
console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR")
if console_static_dir:
dist_dir = Path(console_static_dir)
if not dist_dir.is_dir():
raise ValueError(
f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
)
@app.get("/", include_in_schema=False)
async def _redirect_to_console() -> RedirectResponse:
return RedirectResponse(url="/ui/")
app.mount(
"/ui",
SpaStaticFiles(directory=str(dist_dir), html=True),
name="console",
)
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"],
)