Add Web Dashboard with multi-device control and callback hooks

Features:
- Web Dashboard: FastAPI-based dashboard with Vue.js frontend
  - Multi-device support (ADB, HDC, iOS)
  - Real-time WebSocket updates for task progress
  - Device management with status tracking
  - Task queue with execution controls (start/stop/re-execute)
  - Detailed task information display (thinking, actions, completion messages)
  - Screenshot viewing per device
  - LAN deployment support with configurable CORS

- Callback Hooks: Interrupt and modify task execution
  - step_callback: Called after each step with StepResult
  - before_action_callback: Called before executing action
  - Support for task interruption and dynamic task switching
  - Example scripts demonstrating callback usage

- Configuration: Environment-based configuration
  - .env file support for all settings
  - .env.example template with documentation
  - Model API configuration (base URL, model name, API key)
  - Dashboard configuration (host, port, CORS, device type)
  - Phone agent configuration (delays, max steps, language)

Technical improvements:
- Fixed forward reference issue with StepResult
- Added package exports for callback types and configs
- Enhanced dependencies with FastAPI, WebSocket support
- Thread-safe task execution with device locking
- Async WebSocket broadcasting from sync thread pool

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-01-09 02:20:06 +08:00
parent 9fe189a8f8
commit 3552df23d6
31 changed files with 4221 additions and 8 deletions

13
dashboard/api/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""
API endpoints for the dashboard.
"""
from dashboard.api.devices import router as devices_router
from dashboard.api.tasks import router as tasks_router
from dashboard.api.websocket import router as websocket_router
__all__ = [
"devices_router",
"tasks_router",
"websocket_router",
]

193
dashboard/api/devices.py Normal file
View File

@@ -0,0 +1,193 @@
"""
Device management API endpoints.
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from dashboard.dependencies import get_device_manager, get_ws_manager
from dashboard.models.device import DeviceSchema, DeviceStatus
from dashboard.services.device_manager import DeviceManager
from dashboard.services.websocket_manager import WebSocketManager
router = APIRouter(prefix="/devices", tags=["devices"])
@router.get("", response_model=List[DeviceSchema])
async def list_devices(
device_manager: DeviceManager = Depends(get_device_manager),
):
"""List all connected devices.
Returns:
List of device schemas
"""
devices = await device_manager.refresh_devices()
return [
DeviceSchema(
device_id=d.device_id,
status=d.status,
device_type=d.device_type,
model=d.model,
android_version=d.android_version,
current_app=d.current_app,
last_seen=d.last_seen,
is_connected=d.is_connected,
)
for d in devices
]
@router.get("/refresh")
async def refresh_devices(
device_manager: DeviceManager = Depends(get_device_manager),
ws_manager: WebSocketManager = Depends(get_ws_manager),
):
"""Rescan for connected devices.
Returns:
Refresh confirmation
"""
devices = await device_manager.refresh_devices()
# Broadcast device update
for device in devices:
await ws_manager.broadcast_device_update(
device.device_id,
{
"status": device.status,
"is_connected": device.is_connected,
"model": device.model,
"current_app": device.current_app,
"last_seen": device.last_seen.isoformat(),
},
)
return {"message": "Devices refreshed", "count": len(devices)}
@router.get("/{device_id}", response_model=DeviceSchema)
async def get_device(
device_id: str,
device_manager: DeviceManager = Depends(get_device_manager),
):
"""Get device details.
Args:
device_id: Device identifier
Returns:
Device schema
Raises:
HTTPException: If device not found
"""
device = await device_manager.get_device(device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return DeviceSchema(
device_id=device.device_id,
status=device.status,
device_type=device.device_type,
model=device.model,
android_version=device.android_version,
current_app=device.current_app,
last_seen=device.last_seen,
is_connected=device.is_connected,
)
@router.post("/{device_id}/connect")
async def connect_device(
device_id: str,
address: str | None = None,
device_manager: DeviceManager = Depends(get_device_manager),
):
"""Connect to device via WiFi.
Args:
device_id: Device identifier (for route matching)
address: Device address (IP:PORT)
Returns:
Connection result
"""
if not address:
raise HTTPException(status_code=400, detail="Address is required")
success = await device_manager.connect_device(address)
if success:
return {"message": f"Connected to {address}"}
else:
raise HTTPException(status_code=500, detail=f"Failed to connect to {address}")
@router.post("/{device_id}/disconnect")
async def disconnect_device(
device_id: str,
address: str | None = None,
device_manager: DeviceManager = Depends(get_device_manager),
):
"""Disconnect from device.
Args:
device_id: Device identifier (for route matching)
address: Device address (IP:PORT)
Returns:
Disconnection result
"""
if not address:
raise HTTPException(status_code=400, detail="Address is required")
success = await device_manager.disconnect_device(address)
if success:
return {"message": f"Disconnected from {address}"}
else:
raise HTTPException(
status_code=500, detail=f"Failed to disconnect from {address}"
)
@router.get("/{device_id}/screenshot")
async def get_device_screenshot(
device_id: str,
device_manager: DeviceManager = Depends(get_device_manager),
):
"""Get current device screenshot.
Args:
device_id: Device identifier
Returns:
Screenshot data (base64 encoded)
"""
screenshot = await device_manager.get_screenshot(device_id)
if screenshot is None:
raise HTTPException(status_code=500, detail="Failed to capture screenshot")
return {"device_id": device_id, "screenshot": screenshot}
@router.get("/{device_id}/current-app")
async def get_current_app(
device_id: str,
device_manager: DeviceManager = Depends(get_device_manager),
):
"""Get current app for device.
Args:
device_id: Device identifier
Returns:
Current app package name
"""
app = await device_manager.get_current_app(device_id)
return {"device_id": device_id, "current_app": app}

156
dashboard/api/tasks.py Normal file
View File

@@ -0,0 +1,156 @@
"""
Task management API endpoints.
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from dashboard.config import config
from dashboard.dependencies import get_task_executor
from dashboard.models.task import TaskCreateRequest, TaskSchema, TaskStatus
from dashboard.services.task_executor import TaskExecutor
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/execute", response_model=dict)
async def execute_task(
request: TaskCreateRequest,
executor: TaskExecutor = Depends(get_task_executor),
):
"""Execute task on device.
Args:
request: Task creation request
Returns:
Task ID
"""
# Fill in model config from environment if using defaults
if request.base_url == "http://localhost:8000/v1":
request.base_url = config.MODEL_BASE_URL
if request.model_name == "autoglm-phone-9b":
request.model_name = config.MODEL_NAME
if request.api_key == "EMPTY":
request.api_key = config.MODEL_API_KEY
task_id = await executor.execute_task(request)
return {"task_id": task_id, "message": "Task started"}
@router.post("/{task_id}/stop")
async def stop_task(
task_id: str,
executor: TaskExecutor = Depends(get_task_executor),
):
"""Stop running task.
Args:
task_id: Task identifier
Returns:
Stop confirmation
"""
await executor.stop_task(task_id)
return {"message": f"Task {task_id} stop requested"}
@router.get("", response_model=List[TaskSchema])
async def list_tasks(
executor: TaskExecutor = Depends(get_task_executor),
):
"""List all tasks (active and recent).
Returns:
List of task schemas
"""
return await executor.list_tasks()
@router.get("/{task_id}", response_model=TaskSchema)
async def get_task_status(
task_id: str,
executor: TaskExecutor = Depends(get_task_executor),
):
"""Get task status.
Args:
task_id: Task identifier
Returns:
Task schema
Raises:
HTTPException: If task not found
"""
task = await executor.get_task_status(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.get("/{task_id}/screenshot")
async def get_task_screenshot(
task_id: str,
executor: TaskExecutor = Depends(get_task_executor),
):
"""Get latest screenshot from task execution.
Args:
task_id: Task identifier
Returns:
Screenshot data (base64 encoded)
Raises:
HTTPException: If task not found or device unavailable
"""
task = await executor.get_task_status(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
# Get screenshot from device manager
from dashboard.dependencies import get_device_manager
device_manager = get_device_manager()
screenshot = await device_manager.get_screenshot(task.device_id)
if screenshot is None:
raise HTTPException(
status_code=500, detail="Failed to capture screenshot from device"
)
return {"task_id": task_id, "device_id": task.device_id, "screenshot": screenshot}
@router.get("/stats/summary")
async def get_task_stats(
executor: TaskExecutor = Depends(get_task_executor),
):
"""Get task execution statistics.
Returns:
Task statistics summary
"""
tasks = await executor.list_tasks()
total = len(tasks)
running = sum(1 for t in tasks if t.status == TaskStatus.RUNNING)
completed = sum(1 for t in tasks if t.status == TaskStatus.COMPLETED)
failed = sum(1 for t in tasks if t.status == TaskStatus.FAILED)
stopped = sum(1 for t in tasks if t.status == TaskStatus.STOPPED)
return {
"total": total,
"running": running,
"completed": completed,
"failed": failed,
"stopped": stopped,
"active_count": executor.get_active_task_count(),
}

124
dashboard/api/websocket.py Normal file
View File

@@ -0,0 +1,124 @@
"""
WebSocket API endpoints for real-time updates.
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from dashboard.dependencies import get_ws_manager, get_device_manager
from dashboard.services.websocket_manager import WebSocketManager
from dashboard.services.device_manager import DeviceManager
router = APIRouter(prefix="/ws", tags=["websocket"])
@router.websocket("")
async def websocket_endpoint(
websocket: WebSocket,
client_id: str | None = Query(None),
ws_manager: WebSocketManager = Depends(get_ws_manager),
device_manager: DeviceManager = Depends(get_device_manager),
):
"""WebSocket endpoint for real-time updates.
This endpoint provides real-time updates for:
- Device connection/disconnection
- Task execution progress
- Screenshot updates
- Task completion
Query parameters:
client_id: Optional client ID (auto-generated if not provided)
Message types (client -> server):
- {"type": "subscribe", "device_id": "device_id"} - Subscribe to device updates
- {"type": "unsubscribe", "device_id": "device_id"} - Unsubscribe from device updates
- {"type": "ping"} - Ping server
Message types (server -> client):
- {"type": "device_update", "data": {...}} - Device status update
- {"type": "task_started", "data": {...}} - Task started
- {"type": "task_step", "data": {...}} - Task step update
- {"type": "task_completed", "data": {...}} - Task completed
- {"type": "task_failed", "data": {...}} - Task failed
- {"type": "task_stopped", "data": {...}} - Task stopped
- {"type": "screenshot", "data": {...}} - Screenshot update
- {"type": "error", "data": {...}} - Error occurred
- {"type": "pong"} - Pong response
"""
# Accept connection
client_id = await ws_manager.connect(websocket, client_id)
try:
# Send initial devices
devices = await device_manager.refresh_devices()
for device in devices:
await websocket.send_json({
"type": "device_update",
"data": {
"device_id": device.device_id,
"status": device.status.value,
"device_type": device.device_type.value,
"model": device.model,
"android_version": device.android_version,
"current_app": device.current_app,
"is_connected": device.is_connected,
}
})
# Message loop
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "subscribe":
# Subscribe to device updates
device_id = data.get("device_id", "*")
ws_manager.subscribe_to_device(client_id, device_id)
elif msg_type == "unsubscribe":
# Unsubscribe from device updates
device_id = data.get("device_id")
if device_id:
ws_manager.unsubscribe_from_device(client_id, device_id)
elif msg_type == "ping":
# Respond to ping
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
ws_manager.disconnect(client_id)
except Exception:
ws_manager.disconnect(client_id)
@router.websocket("/device/{device_id}")
async def device_websocket(
device_id: str,
websocket: WebSocket,
ws_manager: WebSocketManager = Depends(get_ws_manager),
):
"""Device-specific WebSocket endpoint for real-time updates.
This endpoint provides real-time updates for a specific device.
Automatically subscribes to the device's updates.
Args:
device_id: Device identifier
"""
# Accept connection and auto-subscribe to device
client_id = await ws_manager.connect(websocket)
ws_manager.subscribe_to_device(client_id, device_id)
try:
# Keep connection alive
while True:
data = await websocket.receive_json()
# Handle client messages
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
ws_manager.disconnect(client_id)
except Exception:
ws_manager.disconnect(client_id)