feat(api): server-rendered Jinja2 Runtime console at /ui/

Replaces the separate Vue/Vite `console/` SPA with a same-origin,
server-rendered console built on a module-level Jinja2 Environment
with select_autoescape(["html","xml"]).

- Add api/console_web.py with /ui/ routes (dashboard, tasks, task
  detail/timeline, config) and a _status_fragment polled every 10s.
- Refactor api/console.py into a typed ConsoleService shared by the
  JSON and HTML routers so validation/persistence cannot drift.
- Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard
  CORS middleware from api/rest.py; GET / now redirects to /ui/.
- Delete the top-level console/ project; add jinja2 and python-multipart
  as direct dependencies and ship templates/CSS/JS via package-data.
- Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir
  and no-CORS regressions, wheel-packaging smoke test).

/console/* JSON endpoints remain unchanged. The console keeps the
trusted-network-only boundary; auth/CSRF is intentionally deferred.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:03:13 +08:00
co-authored by Claude Opus 4.6
parent 56f3f96363
commit e00c50e703
39 changed files with 1890 additions and 2228 deletions
-1
View File
@@ -7,6 +7,5 @@ __pycache__
*.py[cod]
*.sqlite3
tasks
console/node_modules
cloud-console/node_modules
cloud-console/dist
+6 -2
View File
@@ -54,8 +54,12 @@ uv build --package device-agent-runtime
uv build --package device-cloud-platform
```
The Vue/Vite application under `console/` remains an independent npm project;
uv does not install or modify its JavaScript dependencies.
The Runtime API ships a same-origin operator console at `/ui/`, rendered through
Jinja2 templates packaged with `device-agent-runtime`. Start the API
(`uvicorn api.rest:create_app --factory`) and open `/` (it redirects to `/ui/`).
The `/console/*` JSON endpoints remain available for programmatic clients. The
console assumes a trusted local network; it has no authentication, authorization,
or CSRF protection.
## Project Direction
+151 -82
View File
@@ -24,58 +24,160 @@ class RuntimeConfigRequest(BaseModel):
max_steps: int
def create_console_router(
*,
device_manager: DeviceManager,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config_store: DeviceConfigStore,
task_runner: TaskRunner,
) -> Any:
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_screenshot(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_screenshot(record: dict[str, Any]) -> dict[str, Any]:
payload = dict(record)
screenshot_path = payload.get("screenshot_path")
if screenshot_path:
path = Path(str(screenshot_path))
if path.exists():
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
"ascii"
)
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 [device.to_dict() for device in device_manager.list_devices()]
return service.list_devices()
@router.post("/devices", status_code=status.HTTP_201_CREATED)
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
try:
driver_factory = build_driver_factory(
request.driver_type,
request.connection_info,
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
device_id = uuid4().hex
device = device_manager.register_device(
device_id,
driver_factory,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
config_store.add(
device_id=device_id,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
return device.to_dict()
@router.delete(
"/devices/{device_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def unregister_device(device_id: str) -> Response:
if not _has_device(device_manager, device_id):
raise HTTPException(status_code=404, detail="device not found")
device_manager.unregister_device(device_id)
config_store.remove(device_id)
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")
@@ -83,66 +185,33 @@ def create_console_router(
device_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
rows = 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
return service.list_tasks(device_id=device_id, status=status)
@router.get("/tasks/{task_id}")
def task_detail(task_id: str) -> dict[str, Any]:
task = metadata_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
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]]:
if metadata_store.get_task(task_id) is None:
raise HTTPException(status_code=404, detail="task not found")
return [_inline_screenshot(record) for record in timeline.read(task_id)]
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 {"max_steps": _runner_max_steps(task_runner)}
return service.get_runtime_config()
@router.put("/config")
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
if request.max_steps <= 0:
raise HTTPException(status_code=400, detail="max_steps must be positive")
_set_runner_max_steps(task_runner, request.max_steps)
config_store.set_setting("max_steps", request.max_steps)
return {"max_steps": request.max_steps}
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
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
payload = dict(record)
screenshot_path = payload.get("screenshot_path")
if screenshot_path:
path = Path(str(screenshot_path))
if path.exists():
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
"ascii"
)
return payload
def _has_device(device_manager: DeviceManager, device_id: str) -> bool:
return any(device.id == device_id for device in device_manager.list_devices())
def _runner_max_steps(task_runner: TaskRunner) -> int:
config = getattr(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(task_runner: TaskRunner, max_steps: int) -> None:
config = getattr(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
+332
View File
@@ -0,0 +1,332 @@
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 _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,
)
)
@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",
)
+17 -56
View File
@@ -1,8 +1,7 @@
import os
from pathlib import Path
from typing import Any
from api.console import create_console_router
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
@@ -26,28 +25,8 @@ def create_app(
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()
@@ -63,12 +42,6 @@ def create_app(
)
_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
@@ -85,6 +58,18 @@ def create_app(
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()]
@@ -139,33 +124,9 @@ def create_app(
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",
)
app.include_router(create_console_router(console_service))
app.include_router(create_console_web_router(console_service))
mount_console_assets(app)
return app
@@ -35,6 +35,11 @@ button:disabled {
opacity: 0.55;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
@@ -57,6 +62,18 @@ button:disabled {
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;
@@ -140,13 +157,6 @@ button:disabled {
gap: 16px;
}
.tasks-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -336,7 +346,7 @@ select {
text-align: left;
}
.task-row.selected {
.task-row:hover {
border-color: #2f7c67;
box-shadow: 0 0 0 2px #d9efe8;
}
@@ -480,16 +490,6 @@ pre {
color: #1f6b4a;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
@@ -498,55 +498,21 @@ pre {
.sidebar {
position: sticky;
top: 0;
z-index: 2;
border-right: 0;
border-bottom: 1px solid #d9dde5;
flex-direction: row;
align-items: center;
overflow-x: auto;
}
.nav-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-flow: column;
}
.tasks-layout,
.timeline-stage {
grid-template-columns: 1fr;
}
.task-browser {
max-height: none;
}
}
@media (max-width: 680px) {
.workspace {
padding: 14px;
}
.topbar,
.section-title,
.device-row {
align-items: stretch;
}
.topbar,
.device-row,
.settings-form {
flex-direction: column;
.detail-grid,
.form-grid {
grid-template-columns: 1fr;
}
.metrics,
.filters,
.form-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.nav-button {
justify-content: center;
}
.brand {
grid-template-columns: 32px minmax(0, 1fr);
}
}
+25
View File
@@ -0,0 +1,25 @@
// 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);
})();
@@ -0,0 +1,40 @@
<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>
+46
View File
@@ -0,0 +1,46 @@
<!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>
+98
View File
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Config &middot; 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 &quot;{{ device_added }}&quot; 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>&times;</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 %}
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Devices &middot; 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 %}
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Task {{ task.id }} &middot; Apex Console{% endblock %}
{% block body %}
<header class="topbar">
<div>
<h1>Task Detail</h1>
<p><a href="{{ url_for('runtime_console_tasks') }}">&larr; 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="screenshot-frame">
{% if current_step.image_base64 %}
<img
src="data:image/png;base64,{{ current_step.image_base64 }}"
alt="Task step screenshot"
/>
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
<div class="step-data">
<div>
<h3>Tool Call</h3>
<pre>{{ current_step.tool_call | tojson(indent=2) }}</pre>
</div>
<div>
<h3>Result</h3>
<pre>{{ current_step.result | tojson(indent=2) }}</pre>
</div>
</div>
</div>
{% endif %}
</section>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Tasks &middot; 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 %}
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.DS_Store
*.local
-46
View File
@@ -1,46 +0,0 @@
# Apex Agent Console
Independent Vue 3 + Vite SPA for the operator console.
## Run Locally
Start the backend from the repository root:
```bash
uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
Start the frontend from this directory:
```bash
npm install
npm run dev
```
The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`.
Copy `.env.example` to `.env.local` if the backend runs on another host or port.
The backend mounts `/console/*` routes and enables permissive CORS in `create_app()`
for local frontend development.
## Build
```bash
npm run build
```
## Same-Origin, Single-Process Mode
For an edge/dev setup where running a separate `npm run dev` process is too heavy,
the backend can serve the built console directly from the same process:
```bash
VITE_API_BASE_URL= npm run build
RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works
same-origin without CORS. The console is then served at `/ui/` (with `/`
redirecting there); `/console/*` remains the JSON API used by both this mode
and local `npm run dev`. Rebuild (`npm run build`) after frontend changes —
this mode does not hot-reload.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Apex Agent Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-1211
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
{
"name": "apex-agent-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vue-tsc": "^3.3.6"
}
}
-521
View File
@@ -1,521 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import type { Component } from "vue";
import {
ChevronLeft,
ChevronRight,
ListChecks,
LoaderCircle,
MonitorSmartphone,
Plus,
RefreshCw,
Save,
Settings2,
Trash2,
} from "@lucide/vue";
import {
API_BASE_URL,
getConfig,
getTask,
getTimeline,
listDevices,
listTasks,
registerDevice,
unregisterDevice,
updateConfig,
} from "./api";
import type { Device, TaskRecord, TimelineRecord } from "./types";
type ViewId = "dashboard" | "tasks" | "config";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "config", label: "Config", icon: Settings2 },
];
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
const activeView = ref<ViewId>("dashboard");
const loading = ref(false);
const refreshError = ref("");
const devices = ref<Device[]>([]);
const tasks = ref<TaskRecord[]>([]);
const selectedTask = ref<TaskRecord | null>(null);
const timeline = ref<TimelineRecord[]>([]);
const selectedStepIndex = ref(0);
const deviceFilter = ref("");
const statusFilter = ref("");
const deviceError = ref("");
const configError = ref("");
const configSaved = ref("");
const maxSteps = ref(20);
const deviceForm = reactive({
name: "",
driver_type: "wda",
server_url: "http://127.0.0.1:4723",
udid: "",
wda_local_port: "",
});
let refreshTimer: number | undefined;
const currentStep = computed<TimelineRecord | null>(() => {
if (!timeline.value.length) {
return null;
}
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
});
const runningTasks = computed(
() => tasks.value.filter((task) => task.status === "running").length,
);
const failedTasks = computed(
() => tasks.value.filter((task) => task.status === "failed").length,
);
onMounted(async () => {
await refreshAll();
refreshTimer = window.setInterval(() => {
void refreshStatus();
}, 10000);
});
onUnmounted(() => {
if (refreshTimer !== undefined) {
window.clearInterval(refreshTimer);
}
});
async function refreshAll(): Promise<void> {
loading.value = true;
refreshError.value = "";
try {
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
} catch (error) {
refreshError.value = errorMessage(error);
} finally {
loading.value = false;
}
}
async function refreshStatus(): Promise<void> {
try {
await Promise.all([refreshDevices(), refreshTasks()]);
} catch (error) {
refreshError.value = errorMessage(error);
}
}
async function refreshDevices(): Promise<void> {
devices.value = await listDevices();
}
async function refreshTasks(): Promise<void> {
tasks.value = await listTasks({
deviceId: deviceFilter.value,
status: statusFilter.value,
});
if (selectedTask.value) {
await openTask(selectedTask.value.id, false);
}
}
async function refreshConfig(): Promise<void> {
const config = await getConfig();
maxSteps.value = config.max_steps;
}
async function applyTaskFilters(): Promise<void> {
await refreshTasks();
}
async function openTask(taskId: string, switchView = true): Promise<void> {
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
selectedTask.value = task;
timeline.value = records;
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
if (switchView) {
activeView.value = "tasks";
}
}
async function submitDevice(): Promise<void> {
deviceError.value = "";
const connectionInfo: Record<string, unknown> = {};
if (deviceForm.server_url.trim()) {
connectionInfo.server_url = deviceForm.server_url.trim();
}
if (deviceForm.udid.trim()) {
connectionInfo.udid = deviceForm.udid.trim();
}
if (deviceForm.wda_local_port.trim()) {
const port = Number(deviceForm.wda_local_port);
if (!Number.isFinite(port)) {
deviceError.value = "wda_local_port must be a number";
return;
}
connectionInfo.wda_local_port = port;
}
try {
await registerDevice({
driver_type: deviceForm.driver_type,
name: deviceForm.name.trim() || null,
connection_info: connectionInfo,
});
deviceForm.name = "";
deviceForm.udid = "";
deviceForm.wda_local_port = "";
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function removeDevice(device: Device): Promise<void> {
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
return;
}
deviceError.value = "";
try {
await unregisterDevice(device.id);
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function saveConfig(): Promise<void> {
configError.value = "";
configSaved.value = "";
try {
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
maxSteps.value = updated.max_steps;
configSaved.value = "Saved";
} catch (error) {
configError.value = errorMessage(error);
}
}
function previousStep(): void {
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
}
function nextStep(): void {
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
}
function displayDeviceName(device: Device): string {
return device.name || device.id;
}
function findDeviceName(deviceId: string): string {
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
}
function formatDate(value: string | null): string {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function prettyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar" aria-label="Console navigation">
<div class="brand">
<MonitorSmartphone :size="22" aria-hidden="true" />
<div>
<strong>Apex Console</strong>
<span>{{ API_BASE_URL }}</span>
</div>
</div>
<nav class="nav-list">
<button
v-for="item in navItems"
:key="item.id"
class="nav-button"
:class="{ active: activeView === item.id }"
type="button"
@click="activeView = item.id"
>
<component :is="item.icon" :size="18" aria-hidden="true" />
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
</div>
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
<RefreshCw v-else :size="17" aria-hidden="true" />
<span>Refresh</span>
</button>
</header>
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
<section v-if="activeView === 'dashboard'" class="view-grid">
<div class="metrics">
<div class="metric">
<span class="metric-label">Devices</span>
<strong>{{ devices.length }}</strong>
</div>
<div class="metric">
<span class="metric-label">Running</span>
<strong>{{ runningTasks }}</strong>
</div>
<div class="metric">
<span class="metric-label">Failed</span>
<strong>{{ failedTasks }}</strong>
</div>
</div>
<section class="panel">
<div class="section-title">
<h2>Device Status</h2>
</div>
<div v-if="!devices.length" class="empty-state">
<MonitorSmartphone :size="34" aria-hidden="true" />
<span>No devices registered. Add one from Config.</span>
</div>
<ul v-else class="device-list">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<div class="row-meta">
<span class="driver-label">{{ device.driver_type }}</span>
<span class="status-pill" :class="device.status">{{ device.status }}</span>
</div>
</li>
</ul>
</section>
</section>
<section v-if="activeView === 'tasks'" class="tasks-layout">
<section class="panel task-browser">
<div class="section-title">
<h2>Task List</h2>
</div>
<div class="filters">
<label>
Device
<select v-model="deviceFilter" @change="applyTaskFilters">
<option value="">All devices</option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ displayDeviceName(device) }}
</option>
</select>
</label>
<label>
Status
<select v-model="statusFilter" @change="applyTaskFilters">
<option value="">All statuses</option>
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
{{ statusName }}
</option>
</select>
</label>
</div>
<div v-if="!tasks.length" class="empty-state compact">
<ListChecks :size="30" aria-hidden="true" />
<span>No tasks match the current filters.</span>
</div>
<button
v-for="task in tasks"
v-else
:key="task.id"
class="task-row"
:class="{ selected: selectedTask?.id === task.id }"
type="button"
@click="openTask(task.id)"
>
<span class="task-goal">{{ task.goal }}</span>
<span class="task-meta">
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
</span>
<span class="status-pill" :class="task.status">{{ task.status }}</span>
</button>
</section>
<section class="panel timeline-panel">
<div class="section-title">
<h2>Task Detail</h2>
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
{{ selectedTask.status }}
</span>
</div>
<div v-if="!selectedTask" class="empty-state">
<ListChecks :size="34" aria-hidden="true" />
<span>Select a task to inspect its timeline.</span>
</div>
<div v-else class="task-detail">
<dl class="detail-grid">
<div>
<dt>Goal</dt>
<dd>{{ selectedTask.goal }}</dd>
</div>
<div>
<dt>Device</dt>
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
</div>
<div v-if="selectedTask.failure_reason">
<dt>Failure</dt>
<dd>{{ selectedTask.failure_reason }}</dd>
</div>
</dl>
<div class="timeline-controls">
<button
class="icon-button"
type="button"
title="Previous step"
:disabled="selectedStepIndex === 0"
@click="previousStep"
>
<ChevronLeft :size="18" aria-hidden="true" />
</button>
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
<button
class="icon-button"
type="button"
title="Next step"
:disabled="selectedStepIndex >= timeline.length - 1"
@click="nextStep"
>
<ChevronRight :size="18" aria-hidden="true" />
</button>
</div>
<div v-if="!currentStep" class="empty-state compact">
<span>No timeline records captured.</span>
</div>
<div v-else class="timeline-stage">
<div class="screenshot-frame">
<img
v-if="currentStep.image_base64"
:src="`data:image/png;base64,${currentStep.image_base64}`"
alt="Task step screenshot"
/>
<span v-else>No screenshot</span>
</div>
<div class="step-data">
<div>
<h3>Tool Call</h3>
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
</div>
<div>
<h3>Result</h3>
<pre>{{ prettyJson(currentStep.result) }}</pre>
</div>
</div>
</div>
</div>
</section>
</section>
<section v-if="activeView === 'config'" class="config-layout">
<section class="panel">
<div class="section-title">
<h2>Device Configuration</h2>
</div>
<form class="form-grid" @submit.prevent="submitDevice">
<label>
Name
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
</label>
<label>
Driver
<select v-model="deviceForm.driver_type">
<option value="wda">wda</option>
</select>
</label>
<label>
Server URL
<input v-model="deviceForm.server_url" type="url" />
</label>
<label>
UDID
<input v-model="deviceForm.udid" type="text" />
</label>
<label>
WDA local port
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
</label>
<button class="icon-text-button submit-button" type="submit">
<Plus :size="17" aria-hidden="true" />
<span>Add Device</span>
</button>
</form>
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
<ul class="device-list managed">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<button
class="icon-button danger"
type="button"
title="Remove device"
@click="removeDevice(device)"
>
<Trash2 :size="17" aria-hidden="true" />
</button>
</li>
</ul>
</section>
<section class="panel">
<div class="section-title">
<h2>Runtime Parameters</h2>
</div>
<form class="settings-form" @submit.prevent="saveConfig">
<label>
Max steps
<input v-model.number="maxSteps" type="number" min="1" />
</label>
<button class="icon-text-button" type="submit">
<Save :size="17" aria-hidden="true" />
<span>Save</span>
</button>
</form>
<p v-if="configError" class="alert error">{{ configError }}</p>
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
</section>
</section>
</main>
</div>
</template>
-96
View File
@@ -1,96 +0,0 @@
import type {
Device,
RegisterDevicePayload,
RuntimeConfig,
TaskRecord,
TimelineRecord,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
export const API_BASE_URL = (
configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
).replace(/\/$/, "");
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text();
}
throw new Error(message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listDevices(): Promise<Device[]> {
return request<Device[]>("/console/devices");
}
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
return request<Device>("/console/devices", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function unregisterDevice(deviceId: string): Promise<void> {
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
method: "DELETE",
});
}
export function listTasks(filters: {
deviceId?: string;
status?: string;
}): Promise<TaskRecord[]> {
const params = new URLSearchParams();
if (filters.deviceId) {
params.set("device_id", filters.deviceId);
}
if (filters.status) {
params.set("status", filters.status);
}
const query = params.toString();
return request<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
}
export function getTask(taskId: string): Promise<TaskRecord> {
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
}
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
return request<TimelineRecord[]>(
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
);
}
export function getConfig(): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config");
}
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config", {
method: "PUT",
body: JSON.stringify(payload),
});
}
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
-48
View File
@@ -1,48 +0,0 @@
export type DeviceStatus = "idle" | "busy" | "offline" | "error";
export interface Device {
id: string;
name: string | null;
status: DeviceStatus;
driver_type: string;
connection_info: Record<string, unknown>;
}
export type TaskStatus =
| "created"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface TaskRecord {
id: string;
goal: string;
device_id: string;
status: TaskStatus;
created_at: string;
updated_at: string;
completed_at: string | null;
failure_reason: string | null;
}
export interface TimelineRecord {
index: number;
scene: Record<string, unknown>;
prompt: string;
tool_call: Record<string, unknown>;
result: Record<string, unknown>;
timestamp: string;
screenshot_path?: string | null;
image_base64?: string;
}
export interface RuntimeConfig {
max_steps: number;
}
export interface RegisterDevicePayload {
driver_type: string;
name?: string | null;
connection_info: Record<string, unknown>;
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/ui/",
});
+5 -2
View File
@@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
explicit workspace source; the Runtime distribution must never depend on or
package `cloud`.
All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
remains outside the Python workspace and keeps its independent npm lifecycle.
All Python members share the committed root `uv.lock`. The Runtime operator
console is server-rendered by the `api` layer through Jinja2 templates and
static assets packaged with `device-agent-runtime`; there is no separate
frontend project or Node build step for the Runtime console. The unrelated
`cloud-console/` Vue/Vite application keeps its own independent npm lifecycle.
## Change Discipline
+11 -15
View File
@@ -331,23 +331,19 @@ curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
误操作。
如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
Runtime API 自带同源 Web Console,无需额外的前端进程、Node 工具链或
`RUNTIME_CONSOLE_STATIC_DIR`。保持 Runtime API 运行,浏览器访问
`http://127.0.0.1:8000/`(会自动 307 跳转到 `/ui/`)即可:
```bash
cd console
npm install
npm run dev
```
- `/ui/`:设备状态面板,约每 10 秒自动刷新一次;
- `/ui/tasks`:任务列表与筛选;
- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图);
- `/ui/config`:登记/移除设备、调整 `max_steps`
Console 默认连接 `http://127.0.0.1:8000`已由上面启动脚本连接的
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
操作只写入配置,不会自动 connect。
如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
`npm run build`,不支持热更新。
已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中
重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与
`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console
仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。
## 9. 启动云端受管 Host Agent
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,233 @@
## Context
The root Runtime API currently exposes console state and mutations through
`api/console.py` under `/console/*`. Its human interface is a separate
`console/` Vue/Vite SPA. `api/rest.py` only serves that SPA when
`RUNTIME_CONSOLE_STATIC_DIR` points to a built distribution, uses a
SPA-specific 404 fallback, and enables wildcard CORS for cross-origin Vite
development.
The existing `web-console` change is still unarchived and contains the
opposite design decision: an independent SPA with no backend templates. There
is no canonical Runtime-console spec in `openspec/specs/`, so this change
introduces a new capability rather than modifying a pending change's delta
spec. The Host Agent already uses Jinja2, but it is a distinct application
with local-account authentication and must not become a dependency of the
Runtime API.
The Runtime layering rule requires all HTTP, template, static-asset, and form
parsing concerns to remain in `api`. `core`, `driver`, `device`, `tools`,
`perception`, `storage`, and `runtime` retain their current framework-free
contracts.
## Goals / Non-Goals
**Goals:**
- Serve the Runtime operator console from the same FastAPI process at `/ui/`
without Node, Vite, a prebuilt SPA directory, or a second development
process.
- Preserve the observable behavior of the existing device, task, timeline,
and runtime-config console workflows, while retaining `/console/*` JSON
endpoints for programmatic clients.
- Ensure every HTML interpolation is protected by Jinja2 autoescaping and
ship templates and assets in the Runtime wheel.
- Keep mutations server-owned through ordinary POST/Redirect/GET form flows
and reuse one API-local implementation for page and JSON routes.
**Non-Goals:**
- Add authentication, authorization, sessions, CSRF protection, rate limits,
or a public-network deployment model for the Runtime console.
- Change `/agent/task`, `/devices`, MCP, `DeviceManager`, `TaskRunner`,
`TaskMetadataStore`, `Timeline`, or persisted data formats.
- Add new driver types, editable planner settings, Cloud Console features, or
Host Agent console features.
- Retain a generic client-side application framework or a Node build pipeline.
- Edit or archive the pending `web-console` change as part of this change.
## Decisions
### D1: Add a Runtime page router in `api/console_web.py`
`api/console_web.py` will expose `create_console_web_router()` with the `/ui`
prefix. `api/rest.py::create_app()` will build the existing Runtime stores and
manager once, construct the console service once, and include both the JSON
router and the page router in the same FastAPI application. `GET /` will
always redirect to `/ui/` because the console is no longer optional build
output.
The page surface will use stable server routes:
- `GET /ui/` for the device/status dashboard;
- `GET /ui/tasks` for task filtering and browsing;
- `GET /ui/tasks/{task_id}` for one task and its ordered timeline;
- `GET /ui/config` for device registration/removal and `max_steps`;
- POST routes below `/ui/` for device registration, device removal, and
configuration updates.
Successful POST handlers use `303 See Other` redirects. Validation failures
return the originating page with an HTML `400` response and preserved safe
form values. This is preferred over trying to preserve the SPA's client-side
state machine because normal browser navigation and forms are sufficient for
the console's low-frequency operational actions.
An alternative of replacing `/console/*` JSON endpoints with HTML endpoints
was rejected: the JSON API has explicit tests and remains useful to scripts
and future clients. An alternative of a separate FastAPI app was rejected:
it would duplicate the `DeviceManager`, stores, and `TaskRunner` composition.
### D2: Share API-local console operations rather than issuing loopback HTTP
requests
`api/console.py` will gain a small API-local `ConsoleService` (or equivalent
typed operation object) that owns the current console reads and mutations:
device listing/registration/removal, task listing/detail/timeline lookup, and
runtime configuration reads/updates. The existing JSON router and the new
page router receive the same service instance.
The service stays in `api` and delegates to the existing injected
`DeviceManager`, `TaskMetadataStore`, `Timeline`, `DeviceConfigStore`, and
`TaskRunner`. Page handlers MUST NOT make HTTP requests to the process's own
`/console/*` endpoints. This keeps validation, driver allow-listing,
persistence, error mapping, and state mutation single-sourced without moving
web concepts into a lower layer.
Duplicating the route bodies was rejected because future changes could make
JSON and form behavior diverge. Moving the service into `runtime` or
`storage` was rejected because it would introduce HTTP/UI-oriented
application concerns below the API adapter boundary.
### D3: Use a module-level Jinja2 environment and package-owned assets
`api/console_web.py` will construct one module-level Jinja2 `Environment`
using `FileSystemLoader` rooted at `api/templates/runtime_console/` and
`select_autoescape(["html", "xml"])`. Route handlers will render with
`get_template(...).render(...)` and return `HTMLResponse`; they will not
build HTML through f-strings, string concatenation, or post-process rendered
HTML.
Templates will include a shared `base.html`, dashboard, task list, task
detail, configuration page, and any small server-rendered dashboard fragment.
CSS and the narrow polling script will be regular package assets mounted under
`/ui/assets`, not Vite output. The root package will explicitly include the
template, CSS, and JavaScript globs in setuptools package data so an installed
wheel behaves like an editable checkout.
`jinja2>=3.1` and `python-multipart` will be direct root dependencies. The
latter is declared directly even though it is currently available through an
unrelated transitive dependency, because `Request.form()` is part of this
feature's runtime contract.
`starlette.templating.Jinja2Templates` was rejected because direct Jinja2
rendering is explicit, matches the existing Host Agent convention, and avoids
coupling the implementation to helper signature changes. Inline all-CSS HTML
was rejected because a dedicated static stylesheet keeps base templates
readable without restoring a frontend build system.
### D4: Retain server-owned live status with a narrow rendered fragment
The dashboard will keep the current approximately 10-second live-status
refresh through a small static browser script. It fetches an HTML fragment
rendered by the same Jinja2 environment and replaces only the dashboard's
live-status region. The browser does not fetch JSON and recreate console DOM
state; all stateful markup still originates on the server.
Task filters, task selection, timeline step selection, device mutations, and
configuration updates use normal GET/POST navigation. This retains useful
status freshness without reintroducing a SPA framework or making a full-page
reload the only refresh mechanism.
A zero-JavaScript periodic refresh was rejected because it would require full
page reloads and regress the existing dashboard behavior. Reusing the Vue
reactivity layer was rejected because it retains the independent build and
deployment boundary this change removes.
### D5: Preserve JSON compatibility while retiring SPA deployment wiring
The `/console/*` paths, methods, success payloads, error status codes, and
persistence semantics remain unchanged. Existing `tests/test_console_api.py`
continues to be the compatibility baseline. `api/rest.py` removes
`RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, and its SPA-only fallback.
The broad CORS middleware added only for cross-origin Vite development is
removed; same-origin `/ui/` must not depend on it.
The top-level `console/` directory, Vite environment files, package lockfile,
and npm instructions are removed. Root documentation changes to a single
Runtime startup command followed by `/ui/`. The unrelated `cloud-console/`
SPA and its deployment remain unchanged.
Keeping `RUNTIME_CONSOLE_STATIC_DIR` as a deprecated fallback was rejected:
it would preserve an unsupported second rendering path and force every future
console change to be tested twice. A backward-compatible redirect from the
old external Vite development port is impossible because it is a separate
process, so the operator migration is documented as a breaking change.
### D6: Apply autoescaping uniformly and retain the trusted-network boundary
All interpolated device names, IDs, connection values, task goals, failure
reasons, timeline text, JSON-like tool/result data, and errors pass through
the shared autoescaping environment. Structured values use Jinja's `tojson`
filter only in safe text contexts; no current template uses `|safe` or a
global autoescape opt-out. Screenshot bytes remain data sourced from the
existing timeline and are rendered only as the established PNG data URI.
Removing wildcard CORS reduces the old SPA development surface but does not
provide authentication and does not by itself prevent cross-site HTML form
submission. The Runtime console therefore remains documented as
trusted-network-only. Authentication/session/CSRF design is intentionally
separate, so this migration does not create a misleading partial security
model.
## Risks / Trade-offs
- [A task, device name, or JSON value could carry HTML/script content] -> The
shared autoescaping environment, `tojson` for structured output, no safe
bypasses, and XSS regression tests make the protection mechanical.
- [Templates or assets work from a checkout but not an installed wheel] ->
Explicit setuptools package-data rules and a built-wheel smoke test verify
deployment behavior.
- [Removing the npm/Vite workflow disrupts an operator's existing runbook] ->
Mark the removal as breaking, update all Runtime console documentation, and
retain a Git-revert rollback path with no data migration.
- [Removing CORS breaks an undiscovered browser client] -> The existing CORS
configuration was introduced for the deleted Vite development flow; JSON
clients outside a browser remain unaffected. A future browser integration
must add an explicit origin policy rather than restore a wildcard.
- [Base64 screenshots make large task-detail responses expensive] -> Preserve
the existing bounded-task behavior and do not change timeline storage or
transfer format in this rendering migration.
- [The pending `web-console` change still contains a SPA decision and one
manual task] -> Record this change as the rendering-mechanism successor;
reconcile the older change only after the new server-rendered browser
workflow has been manually verified.
## Migration Plan
1. Add direct dependencies and package-data declarations, then create the
Jinja2 environment, templates, static assets, API-local console service,
and `/ui/` page routes while preserving existing JSON-route tests.
2. Add focused template, page-route, mutation/PRG, fragment-refresh, and XSS
tests. Build and install the Runtime wheel in an isolated environment to
verify package resources are present.
3. Remove `console/`, SPA static mounting, the static-directory environment
variable, Vite-specific CORS, and obsolete ignore/configuration files.
4. Update root Runtime and macOS setup documentation to direct operators to
`/ui/`, and document the removed npm/static-directory workflow.
5. Run formatting, linting, non-integration Runtime tests, wheel build/smoke
checks, strict OpenSpec validation, and a browser walkthrough covering
dashboard refresh, task replay, device mutation, and `max_steps` update.
Rollback is a source revert. It restores the Vue sources and static mount if
needed and does not alter device configuration, task metadata, or timeline
data, so no data rollback or schema migration is required.
## Open Questions
- The remaining manual verification task in the pending `web-console` change
must be reconciled before that older change is archived. It is not safe to
mark it complete solely because this proposal exists; the new `/ui/`
browser walkthrough supplies the replacement evidence after implementation.
- Authentication and CSRF protection remain deliberately deferred. Any plan to
expose the Runtime console beyond a trusted local network requires a
separate threat model and change proposal.
@@ -0,0 +1,60 @@
## Why
The local Runtime console is currently a separately built Vue/Vite SPA. It
requires a Node toolchain for development and an optional static-directory
configuration for same-process serving, even though its data and mutations
already live in the Runtime FastAPI process. Rendering the console with Jinja2
will make the operator surface deploy with the Runtime itself while preserving
the existing REST contract for programmatic clients.
## What Changes
- Add a same-origin, server-rendered Runtime console under `/ui/`, with Jinja2
pages for device status, task browsing/detail/timeline replay, device
registration/removal, and runtime configuration.
- Keep the existing `/console/*` JSON endpoints and make page handlers and
JSON handlers share API-layer console operations so their observable
registration, deletion, filtering, and configuration semantics cannot
drift.
- Package console templates and static assets with `device-agent-runtime`, add
direct Jinja2 and HTML form-parsing dependencies, and render every HTML page
through one autoescaping template environment.
- Replace the Vue/Vite `console/` project, `RUNTIME_CONSOLE_STATIC_DIR`, and
SPA fallback static mount with Runtime-owned templates and normal static
assets. Remove the permissive CORS configuration that existed only for
cross-origin Vite development.
- **BREAKING**: the independent `console/` npm workflow and
`RUNTIME_CONSOLE_STATIC_DIR` deployment mode are removed. Operators will
start the Runtime API normally and open `/ui/`; JSON API paths remain
unchanged.
## Capabilities
### New Capabilities
- `runtime-console-template-rendering`: Same-origin Jinja2-rendered Runtime
console pages, automatic HTML escaping, form-based mutations, and packaged
Runtime-owned web assets.
### Modified Capabilities
- None. The existing canonical specs do not define the pending `web-console`
SPA, and the `/console/*` JSON API contract remains unchanged.
## Impact
- Affected code: `api/rest.py`, `api/console.py`, a new API-layer page router,
Runtime template/static asset directories, root `pyproject.toml`, and
console-focused tests.
- Removed code/assets: top-level `console/` Vue/Vite sources, Node lockfile,
Vite environment configuration, and SPA deployment wiring.
- Documentation: Runtime startup and console guidance in `README.md`,
`docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` change to describe
the built-in `/ui/` console.
- Security boundary: this change preserves the existing trusted-network,
unauthenticated Runtime console assumption. It does not add authentication,
authorization, or session/CSRF protection; non-trusted exposure needs a
separate security change.
- Architecture: all new HTTP, HTML, and template concerns remain in the outer
`api` layer. No `core`, `driver`, `device`, `tools`, `perception`, or
`runtime` package gains web-framework dependencies.
@@ -0,0 +1,113 @@
## ADDED Requirements
### Requirement: Runtime console pages SHALL be served by the Runtime FastAPI application
The Runtime FastAPI application SHALL serve its operator console from
same-origin `/ui/` routes without requiring a separately running frontend
process, a prebuilt SPA directory, or `RUNTIME_CONSOLE_STATIC_DIR`. `GET /`
SHALL redirect an operator to `/ui/`.
#### Scenario: Open the built-in console without static-directory configuration
- **WHEN** the Runtime application starts without `RUNTIME_CONSOLE_STATIC_DIR`
- **THEN** `GET /` redirects to `/ui/` and `GET /ui/` returns an HTML
dashboard rendered by the Runtime process
#### Scenario: Navigate the operator workflows through page routes
- **WHEN** an operator opens `/ui/tasks`, `/ui/tasks/{task_id}`, or
`/ui/config`
- **THEN** the Runtime returns HTML pages for task browsing, task timeline
detail, and device/runtime configuration respectively
### Requirement: Runtime console HTML SHALL use one autoescaping template environment
Every Runtime console HTML response SHALL be rendered through one
process-wide Jinja2 environment configured to autoescape `.html` and `.xml`
templates. Page handlers SHALL NOT construct HTML through f-strings, string
concatenation, or post-process rendered output to bypass that environment.
#### Scenario: Untrusted device and task values are rendered safely
- **WHEN** a device name, task goal, failure reason, or timeline value contains
`<script>alert(1)</script>`
- **THEN** the rendered console HTML contains an escaped text representation
and contains no script element originating from that value
#### Scenario: A future page inherits HTML autoescaping
- **WHEN** a future Runtime console route renders a `.html` template through
the shared environment
- **THEN** its interpolated values are HTML-escaped without route-specific
escaping configuration
### Requirement: Runtime console pages SHALL preserve console operational workflows
The server-rendered console SHALL let an operator inspect device status, list
and filter tasks, inspect a task's ordered timeline including available
screenshots, register or remove a supported device, and view or update
`max_steps`. Successful configuration mutations SHALL use POST/Redirect/GET;
invalid form input SHALL be re-rendered as a readable HTML error without
applying a partial mutation.
#### Scenario: Browse filtered tasks and inspect a timeline
- **WHEN** an operator selects a device or status filter and opens a known
task
- **THEN** the task list contains only matching tasks and the task page renders
its timeline in step order with its screenshot when one exists
#### Scenario: Register a device from the configuration page
- **WHEN** an operator submits valid supported-device form values
- **THEN** the Runtime registers and persists the device, responds with a
redirect to the configuration page, and the device is visible after the
redirect
#### Scenario: Reject invalid configuration without partial mutation
- **WHEN** an operator submits an unsupported driver type, malformed
connection value, or non-positive `max_steps`
- **THEN** the Runtime returns an HTML validation error, preserves the prior
Runtime/configuration state, and does not perform a redirect
### Requirement: Dashboard live status SHALL remain server-rendered
The dashboard SHALL retain periodic live-status refresh without restoring a
client-side application framework. Its browser enhancement SHALL request a
server-rendered HTML fragment and replace only the live-status region; it
SHALL NOT rebuild console state from a JSON API response.
#### Scenario: Refresh dashboard status after the polling interval
- **WHEN** the dashboard refresh enhancement runs while the Runtime is
available
- **THEN** it retrieves a Jinja2-rendered status fragment and updates the
dashboard's live-status region without a full-page reload
### Requirement: Existing console JSON API SHALL remain compatible
The Runtime SHALL continue to expose the existing `/console/devices`,
`/console/tasks`, `/console/tasks/{task_id}`, `/console/tasks/{task_id}/timeline`,
and `/console/config` JSON endpoints with their existing methods, status
codes, payloads, filtering behavior, and persistence semantics. Page and JSON
routes SHALL use the same API-local console operations rather than issuing
HTTP requests to each other.
#### Scenario: Programmatic client reads console data after the UI migration
- **WHEN** a client calls an existing `GET /console/*` endpoint after the
server-rendered console is deployed
- **THEN** it receives the same JSON response shape and status behavior as
before the migration
#### Scenario: A page mutation is visible through the JSON API
- **WHEN** an operator registers or removes a device or updates `max_steps`
through a `/ui/` form
- **THEN** the corresponding `/console/*` JSON endpoint reports the same
resulting Runtime state
### Requirement: Runtime console templates and assets SHALL ship with the Python package
The Runtime distribution SHALL package all console templates and static assets
needed by `/ui/`. The Runtime console SHALL not depend on the top-level Vue/Vite
`console/` project, Node package installation, Vite configuration, or the
SPA-only static-directory mount. Same-origin console operation SHALL not
require wildcard CORS configured for Vite development.
#### Scenario: Run the console from an installed Runtime wheel
- **WHEN** `device-agent-runtime` is built and installed outside the source
checkout
- **THEN** the Runtime can serve `/ui/` and its required CSS/browser assets
from packaged resources
#### Scenario: Start a Runtime after the SPA workflow is removed
- **WHEN** an operator starts the Runtime API using the documented Python
command
- **THEN** the console is available at `/ui/` without `npm install`,
`npm run build`, `VITE_API_BASE_URL`, or `RUNTIME_CONSOLE_STATIC_DIR`
@@ -0,0 +1,42 @@
## 1. Runtime package and web-resource setup
- [x] 1.1 Add direct `jinja2>=3.1` and `python-multipart` Runtime dependencies, configure setuptools package-data for Runtime console templates/CSS/JavaScript, regenerate `uv.lock`, and verify `uv lock --check`.
- [x] 1.2 Create the Runtime-owned template and static-asset layout under `api/` for the `/ui/` console, with names suitable for wheel packaging and standard static-file serving.
- [x] 1.3 Add a package-resource smoke test that builds and installs `device-agent-runtime` outside the source checkout and confirms `/ui/` can find its templates and assets.
## 2. Shared API-layer console operations
- [x] 2.1 Refactor `api/console.py` to expose an API-local typed console service for device, task, timeline, and runtime-config reads and mutations, preserving current validation and error semantics.
- [x] 2.2 Rewire `create_console_router()` to use the shared service and retain every existing `/console/*` method, JSON response shape, status code, filtering rule, and persistence behavior.
- [x] 2.3 Update `api/rest.py` to construct one shared service from its existing injected Runtime state and pass it to both JSON and HTML console routers without changing lower-layer dependencies.
## 3. Jinja2 Runtime console routes and pages
- [x] 3.1 Implement `api/console_web.py` with one module-level Jinja2 `Environment`, `FileSystemLoader`, `select_autoescape(["html", "xml"])`, and a small `HTMLResponse` render helper.
- [x] 3.2 Mount same-origin `/ui/` page routes and `/ui/assets` static resources; make `GET /` redirect to `/ui/` unconditionally.
- [x] 3.3 Implement the shared base layout, dashboard, and Jinja-rendered live-status fragment, including the small polling enhancement that replaces only the live region.
- [x] 3.4 Implement server-rendered task list/filter and task-detail/timeline pages, including ordered records, safe structured tool/result output, and available screenshot data URIs.
- [x] 3.5 Implement the configuration page and POST/Redirect/GET device registration/removal and `max_steps` update handlers, with readable `400` HTML validation errors that retain submitted safe values and perform no partial mutation.
- [x] 3.6 Port the existing console visual layout to package-owned CSS and the narrow polling script without adding a JavaScript framework or a frontend build step.
## 4. Retire SPA deployment wiring and update documentation
- [x] 4.1 Remove `RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, the SPA fallback, and the wildcard CORS middleware used only for Vite development from `api/rest.py`.
- [x] 4.2 Delete the top-level `console/` Vue/Vite project and remove its obsolete environment, npm, and ignore-file references while leaving `cloud-console/` untouched.
- [x] 4.3 Update `README.md`, `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` to document normal Runtime startup followed by `/ui/`, the breaking removal of the npm/static-directory workflow, and the trusted-network-only security boundary.
- [x] 4.4 Remove stale Docker ignore/configuration entries that only described the deleted Runtime SPA, without changing Cloud Console build or deployment behavior.
## 5. Automated verification
- [x] 5.1 Keep and extend console JSON API tests to prove `/console/*` compatibility before and after page-route mutations.
- [x] 5.2 Add template tests covering every Runtime console template, module-level autoescape configuration, XSS probes in device/task/timeline/configuration values, safe structured JSON output, and absence of unintended `|safe` bypasses.
- [x] 5.3 Add FastAPI `TestClient` coverage for root redirect, page navigation, empty/populated dashboard state, task filters/detail/timeline, dashboard fragment refresh, static assets, form PRG success paths, and invalid-form no-mutation paths.
- [x] 5.4 Add regression coverage that same-origin `/ui/` works without `RUNTIME_CONSOLE_STATIC_DIR` and does not depend on the removed wildcard CORS middleware.
## 6. Validation and migration handoff
- [x] 6.1 Run targeted formatting, lint, compile, and Runtime console tests, then run the repository non-integration test suite; record any pre-existing failures separately from this change.
- [x] 6.2 Build the Runtime wheel and run the isolated package-resource smoke test after the final dependency lock update.
- [x] 6.3 Run `openspec validate runtime-console-jinja2-templates --strict` and resolve all validation failures.
- [ ] 6.4 Perform a browser walkthrough of dashboard live refresh, task filtering/timeline replay, valid and invalid device configuration, device removal, and `max_steps` update using a real Runtime process.
- [ ] 6.5 After human browser verification, reconcile the remaining manual verification and superseded SPA decision in the pending `web-console` change before any archive decision; do not mark it complete from automated evidence alone.
+9
View File
@@ -8,10 +8,12 @@ dependencies = [
"Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"mcp>=1.27,<2",
"openai>=1.0.0",
"paddlepaddle>=3.0.0",
"paddleocr>=3.0.0",
"python-multipart>=0.0.20",
"uvicorn[standard]>=0.30.0",
]
@@ -53,6 +55,13 @@ include = [
"workflow*",
]
[tool.setuptools.package-data]
api = [
"templates/runtime_console/*.html",
"static/runtime_console/*.css",
"static/runtime_console/*.js",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --import-mode=importlib"
+31 -6
View File
@@ -80,17 +80,18 @@ def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) ->
"task-old",
]
assert [
task["id"]
for task in client.get("/console/tasks?device_id=iphone-1").json()
task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
] == ["task-old"]
assert [task["id"] for task in client.get("/console/tasks?status=running").json()] == [
"task-new"
]
assert [
task["id"] for task in client.get("/console/tasks?status=running").json()
] == ["task-new"]
assert client.get("/console/tasks/task-old").json()["goal"] == "open settings"
assert client.get("/console/tasks/missing").status_code == 404
def test_console_timeline_inlines_screenshot_and_handles_empty_history(tmp_path) -> None:
def test_console_timeline_inlines_screenshot_and_handles_empty_history(
tmp_path,
) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
@@ -193,3 +194,27 @@ def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> Non
assert runner.config.max_steps == 31
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
def test_console_json_reflects_page_form_mutations(tmp_path) -> None:
"""A device registered via the /ui/ form must be visible through /console/* JSON."""
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "From Form",
"driver_type": "wda",
"server_url": "http://127.0.0.1:4723",
"udid": "form-udid",
"wda_local_port": "8100",
},
follow_redirects=False,
)
assert response.status_code == 303
json_devices = client.get("/console/devices").json()
assert len(json_devices) == 1
assert json_devices[0]["name"] == "From Form"
assert json_devices[0]["connection_info"]["udid"] == "form-udid"
+79
View File
@@ -0,0 +1,79 @@
"""Smoke test that console templates and assets ship inside the Runtime wheel.
Builds ``device-agent-runtime`` into a temporary directory, installs it into an
isolated venv that cannot reach the source checkout, and asserts the packaged
``api`` package carries the Jinja2 templates and static assets needed by
``/ui/``. This guards against setuptools package-data regressions that would
let the console work from an editable checkout but break from a real install.
"""
from __future__ import annotations
import subprocess
import venv
from pathlib import Path
import pytest
def _run(cmd: list[str], *, cwd: Path | None = None) -> str:
return subprocess.check_output(
cmd,
cwd=cwd,
stderr=subprocess.STDOUT,
text=True,
)
@pytest.mark.integration
def test_runtime_wheel_packages_console_templates_and_assets(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parent.parent
wheel_dir = tmp_path / "wheels"
wheel_dir.mkdir()
_run(
["uv", "build", "--package", "device-agent-runtime", "--wheel", "--no-sources"],
cwd=repo_root,
)
wheels = list(repo_root.glob("dist/*.whl"))
assert wheels, "uv build did not produce a wheel"
wheel_path = wheels[0]
venv_dir = tmp_path / "venv"
venv.create(venv_dir, with_pip=True, clear=True)
pip = str(venv_dir / "Scripts" / "pip.exe")
if not Path(pip).exists():
pip = str(venv_dir / "bin" / "pip")
_run([pip, "install", str(wheel_path)], cwd=tmp_path)
python = str(venv_dir / "Scripts" / "python.exe")
if not Path(python).exists():
python = str(venv_dir / "bin" / "python")
probe = _run(
[
python,
"-c",
(
"from importlib.resources import files; "
"api_root = files('api'); "
"templates = sorted(p.name for p in "
"(api_root / 'templates' / 'runtime_console').iterdir()); "
"assets = sorted(p.name for p in "
"(api_root / 'static' / 'runtime_console').iterdir()); "
"print(','.join(templates)); "
"print(','.join(assets))"
),
],
cwd=tmp_path,
)
template_names, asset_names = probe.strip().splitlines()
assert "base.html" in template_names
assert "dashboard.html" in template_names
assert "config.html" in template_names
assert "console.css" in asset_names
assert "dashboard.js" in asset_names
# Clean up the build artifact so it does not leak into the working tree.
for wheel in wheels:
wheel.unlink()
+393
View File
@@ -0,0 +1,393 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
from pathlib import Path
import pytest
from core.models import Task
from device.manager import DeviceManager
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20, FakeDriver
def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
app = create_app(
manager=manager or DeviceManager(),
metadata_store=metadata_store,
task_runner=runner,
device_config_store=config_store
or DeviceConfigStore(tmp_path / "device_config.sqlite3"),
timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
)
return TestClient(app), metadata_store
_TEMPLATE_NAMES = [
"base.html",
"dashboard.html",
"_status_fragment.html",
"tasks.html",
"task_detail.html",
"config.html",
]
# -- 5.2 Template tests ------------------------------------------------------
def test_module_jinja_environment_autoescapes_html_and_xml() -> None:
from api.console_web import _ENV
# select_autoescape(["html", "xml"]) returns a callable used by Jinja2.
assert callable(_ENV.autoescape)
assert _ENV.autoescape("foo.html") is True
assert _ENV.autoescape("foo.xml") is True
def test_every_console_template_is_known_and_loadable() -> None:
from api.console_web import _ENV
for name in _TEMPLATE_NAMES:
assert _ENV.get_template(name) is not None
def test_no_template_uses_safe_filter_bypass() -> None:
template_dir = (
Path(__file__).resolve().parent.parent / "api" / "templates" / "runtime_console"
)
for path in template_dir.glob("*.html"):
source = path.read_text(encoding="utf-8")
assert "| safe" not in source, f"{path.name} uses |safe bypass"
assert "|safe" not in source, f"{path.name} uses |safe bypass"
def test_dashboard_escapes_untrusted_device_name(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"dev-xss",
lambda: FakeDriver(),
name="<script>alert(1)</script>",
driver_type="wda",
)
client, _ = _client(tmp_path, manager=manager)
body = client.get("/ui/").text
assert "&lt;script&gt;" in body
assert "<script>alert(1)</script>" not in body
def test_tasks_list_escapes_untrusted_goal(tmp_path) -> None:
client, metadata_store = _client(tmp_path)
metadata_store.create_task(
Task(
id="task-xss",
goal="<script>alert('xss')</script>",
device_id="dev-1",
)
)
body = client.get("/ui/tasks").text
assert "&lt;script&gt;" in body
assert "<script>alert('xss')</script>" not in body
def test_task_detail_escapes_failure_reason_and_structured_output(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(
id="task-detail",
goal="do thing",
device_id="dev-1",
failure_reason="<script>alert('fail')</script>",
)
)
timeline.append(
task_id="task-detail",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="do thing",
tool_call={"action": "<script>", "args": {"x": 1}},
result={"err": "</script><script>alert(1)</script>"},
screenshot=PNG_10X20,
)
body = client.get("/ui/tasks/task-detail").text
assert "<script>alert('fail')</script>" not in body
assert "</script><script>" not in body
# Structured JSON output uses tojson which escapes angle brackets.
assert "\\u003c" in body or "&lt;script&gt;" in body
def test_config_form_error_preserves_and_escapes_submitted_name(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.post(
"/ui/config/devices",
data={
"name": "<script>alert(1)</script>",
"driver_type": "bad-driver",
},
)
assert response.status_code == 400
assert "<script>alert(1)</script>" not in response.text
assert "&lt;script&gt;" in response.text
# -- 5.3 TestClient page-route coverage --------------------------------------
def test_root_redirects_to_ui(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/"
def test_dashboard_serves_html_with_empty_state(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "No devices registered" in response.text
def test_dashboard_shows_populated_metrics_and_devices(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk iPhone",
driver_type="wda",
)
client, metadata_store = _client(tmp_path, manager=manager)
metadata_store.create_task(
Task(id="t-running", goal="run", device_id="iphone-1", status="running")
)
metadata_store.create_task(
Task(id="t-failed", goal="fail", device_id="iphone-1", status="failed")
)
body = client.get("/ui/").text
assert "Desk iPhone" in body
assert "iphone-1" in body
def test_status_fragment_endpoint_returns_html_partial(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/_status_fragment")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Device Status" in response.text
def test_tasks_page_supports_device_and_status_filters(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk",
driver_type="wda",
)
client, metadata_store = _client(tmp_path, manager=manager)
older = Task(
id="task-old",
goal="open settings",
device_id="iphone-1",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
newer = Task(
id="task-new",
goal="search",
device_id="iphone-2",
status="running",
created_at=datetime(2026, 1, 2, tzinfo=UTC),
updated_at=datetime(2026, 1, 2, tzinfo=UTC),
)
metadata_store.create_task(older)
metadata_store.create_task(newer)
body_all = client.get("/ui/tasks").text
assert "task-old" in body_all
assert "task-new" in body_all
body_filtered = client.get("/ui/tasks?device_id=iphone-1").text
assert "task-old" in body_filtered
assert "task-new" not in body_filtered
body_status = client.get("/ui/tasks?status=running").text
assert "task-new" in body_status
assert "task-old" not in body_status
def test_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(id="task-with-timeline", goal="tap search", device_id="iphone-1")
)
timeline.append(
task_id="task-with-timeline",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="tap search",
tool_call={"action": "tap", "args": {"x": 1, "y": 2}},
result={"ok": True},
screenshot=PNG_10X20,
)
body = client.get("/ui/tasks/task-with-timeline").text
expected_data_uri = "data:image/png;base64," + base64.b64encode(PNG_10X20).decode(
"ascii"
)
assert expected_data_uri in body
assert "tap" in body
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/tasks/does-not-exist")
assert response.status_code == 404
def test_config_page_lists_supported_drivers_and_current_max_steps(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 5)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
body = client.get("/ui/config").text
assert "wda" in body
assert 'value="5"' in body
def test_register_device_prg_redirects_and_persists(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "Desk iPhone",
"driver_type": "wda",
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
"wda_local_port": "8100",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"].endswith("/ui/config")
assert len(config_store.list()) == 1
def test_register_device_rejects_bad_driver_without_partial_mutation(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={"name": "Bad", "driver_type": "android"},
)
assert response.status_code == 400
assert config_store.list() == []
assert "unsupported driver_type" in response.text
def test_register_device_rejects_non_numeric_port_without_partial_mutation(
tmp_path,
) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "Bad Port",
"driver_type": "wda",
"wda_local_port": "not-a-number",
},
)
assert response.status_code == 400
assert config_store.list() == []
assert "wda_local_port must be a number" in response.text
def test_remove_device_prg_redirects_and_removes(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.add(
device_id="removable-1",
name="To Remove",
driver_type="wda",
connection_info={"udid": "abc"},
)
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices/removable-1/delete",
follow_redirects=False,
)
assert response.status_code == 303
assert config_store.list() == []
def test_update_max_steps_prg_redirects_and_applies(tmp_path) -> None:
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner)
response = client.post(
"/ui/config/max-steps",
data={"max_steps": "25"},
follow_redirects=False,
)
assert response.status_code == 303
assert runner.config.max_steps == 25
def test_update_max_steps_rejects_non_positive_without_partial_mutation(
tmp_path,
) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 10)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
response = client.post("/ui/config/max-steps", data={"max_steps": "0"})
assert response.status_code == 400
assert runner.config.max_steps == 10
assert "max_steps must be positive" in response.text
def test_update_max_steps_rejects_non_integer(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.post("/ui/config/max-steps", data={"max_steps": "abc"})
assert response.status_code == 400
assert "max_steps must be an integer" in response.text
def test_static_assets_are_served(tmp_path) -> None:
client, _ = _client(tmp_path)
assert client.get("/ui/assets/console.css").status_code == 200
assert client.get("/ui/assets/dashboard.js").status_code == 200
# -- 5.4 Regression: no SPA static dir, no wildcard CORS ---------------------
def test_ui_works_without_runtime_console_static_dir(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("RUNTIME_CONSOLE_STATIC_DIR", raising=False)
client, _ = _client(tmp_path)
assert client.get("/ui/").status_code == 200
def test_runtime_app_does_not_register_wildcard_cors(tmp_path) -> None:
client, _ = _client(tmp_path)
# A same-origin browser client must not require CORS preflight. If wildcard
# CORS were still registered, an explicit Origin header would produce
# access-control-allow-origin in the response; assert it is absent for an
# arbitrary same-origin page request.
response = client.get(
"/ui/",
headers={"Origin": "http://127.0.0.1:8000"},
)
assert response.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in response.headers}
Generated
+4
View File
@@ -403,10 +403,12 @@ dependencies = [
{ name = "appium-python-client" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "mcp" },
{ name = "openai" },
{ name = "paddleocr" },
{ name = "paddlepaddle" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -421,10 +423,12 @@ requires-dist = [
{ name = "appium-python-client", specifier = ">=5.1.1" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "mcp", specifier = ">=1.27,<2" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "paddleocr", specifier = ">=3.0.0" },
{ name = "paddlepaddle", specifier = ">=3.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]