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:
8
dashboard/__init__.py
Normal file
8
dashboard/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
AutoGLM Dashboard - Web-based multi-device control interface.
|
||||
|
||||
This package provides a FastAPI web application for controlling multiple
|
||||
Android (ADB), HarmonyOS (HDC), and iOS devices through a web interface.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
13
dashboard/api/__init__.py
Normal file
13
dashboard/api/__init__.py
Normal 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
193
dashboard/api/devices.py
Normal 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
156
dashboard/api/tasks.py
Normal 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
124
dashboard/api/websocket.py
Normal 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)
|
||||
52
dashboard/config.py
Normal file
52
dashboard/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Dashboard configuration settings.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class DashboardConfig:
|
||||
"""Dashboard configuration."""
|
||||
|
||||
# Server settings
|
||||
HOST: str = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("DASHBOARD_PORT", "8080"))
|
||||
DEBUG: bool = os.getenv("DASHBOARD_DEBUG", "false").lower() == "true"
|
||||
|
||||
# CORS settings
|
||||
CORS_ORIGINS: List[str] = os.getenv(
|
||||
"DASHBOARD_CORS_ORIGINS", "*"
|
||||
).split(",") if os.getenv("DASHBOARD_CORS_ORIGINS") else ["*"]
|
||||
|
||||
# Device settings
|
||||
DEFAULT_DEVICE_TYPE: str = os.getenv("DEFAULT_DEVICE_TYPE", "adb")
|
||||
|
||||
# Task settings
|
||||
MAX_CONCURRENT_TASKS: int = int(os.getenv("MAX_CONCURRENT_TASKS", "10"))
|
||||
TASK_TIMEOUT_SECONDS: int = int(os.getenv("TASK_TIMEOUT_SECONDS", "300"))
|
||||
|
||||
# Screenshot settings
|
||||
SCREENSHOT_QUALITY: int = int(os.getenv("SCREENSHOT_QUALITY", "80"))
|
||||
SCREENSHOT_THROTTLE_MS: int = int(os.getenv("SCREENSHOT_THROTTLE_MS", "500"))
|
||||
|
||||
# Model settings (defaults from environment)
|
||||
MODEL_BASE_URL: str = os.getenv("PHONE_AGENT_BASE_URL", "http://localhost:8000/v1")
|
||||
MODEL_NAME: str = os.getenv("PHONE_AGENT_MODEL", "autoglm-phone-9b")
|
||||
MODEL_API_KEY: str = os.getenv("PHONE_AGENT_API_KEY", "EMPTY")
|
||||
|
||||
# Task history
|
||||
MAX_TASK_HISTORY: int = int(os.getenv("MAX_TASK_HISTORY", "100"))
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
# Global config instance
|
||||
config = DashboardConfig()
|
||||
39
dashboard/dependencies.py
Normal file
39
dashboard/dependencies.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Dependency injection for the dashboard.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from dashboard.config import config
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from dashboard.services.task_executor import TaskExecutor
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
|
||||
# Global service instances
|
||||
_device_manager: DeviceManager | None = None
|
||||
_task_executor: TaskExecutor | None = None
|
||||
_ws_manager: WebSocketManager | None = None
|
||||
|
||||
|
||||
def get_device_manager() -> DeviceManager:
|
||||
"""Get the device manager instance."""
|
||||
global _device_manager
|
||||
if _device_manager is None:
|
||||
_device_manager = DeviceManager(device_type=config.DEFAULT_DEVICE_TYPE)
|
||||
return _device_manager
|
||||
|
||||
|
||||
def get_task_executor() -> TaskExecutor:
|
||||
"""Get the task executor instance."""
|
||||
global _task_executor
|
||||
if _task_executor is None:
|
||||
_task_executor = TaskExecutor(get_device_manager())
|
||||
return _task_executor
|
||||
|
||||
|
||||
def get_ws_manager() -> WebSocketManager:
|
||||
"""Get the WebSocket manager instance."""
|
||||
global _ws_manager
|
||||
if _ws_manager is None:
|
||||
_ws_manager = WebSocketManager()
|
||||
return _ws_manager
|
||||
175
dashboard/main.py
Normal file
175
dashboard/main.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
AutoGLM Dashboard - FastAPI Main Application.
|
||||
|
||||
This is the main entry point for the web dashboard.
|
||||
Run with: uvicorn dashboard.main:app --host 0.0.0.0 --port 8080 --reload
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from dashboard.api import devices_router, tasks_router, websocket_router
|
||||
from dashboard.config import config
|
||||
from dashboard.dependencies import (
|
||||
get_device_manager,
|
||||
get_task_executor,
|
||||
get_ws_manager,
|
||||
)
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from dashboard.services.task_executor import TaskExecutor
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
|
||||
# Load .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
print("=" * 50)
|
||||
print("AutoGLM Dashboard Starting...")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize services
|
||||
device_manager = get_device_manager()
|
||||
task_executor = get_task_executor()
|
||||
ws_manager = get_ws_manager()
|
||||
|
||||
# Link services
|
||||
task_executor.set_ws_manager(ws_manager)
|
||||
|
||||
# Scan for devices on startup
|
||||
print("Scanning for devices...")
|
||||
try:
|
||||
devices = await device_manager.refresh_devices()
|
||||
print(f"Found {len(devices)} device(s)")
|
||||
for device in devices:
|
||||
status = "connected" if device.is_connected else "disconnected"
|
||||
print(f" - {device.device_id} ({status})")
|
||||
except Exception as e:
|
||||
print(f"Error scanning devices: {e}")
|
||||
|
||||
print("=" * 50)
|
||||
print(f"Dashboard running on http://{config.HOST}:{config.PORT}")
|
||||
print(f"API docs: http://{config.HOST}:{config.PORT}/docs")
|
||||
print("=" * 50)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down dashboard...")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="AutoGLM Dashboard",
|
||||
description="Web-based multi-device control interface for AutoGLM",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS for LAN access
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Global exception handler."""
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": str(exc), "type": type(exc).__name__},
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(devices_router, prefix="/api")
|
||||
app.include_router(tasks_router, prefix="/api")
|
||||
app.include_router(websocket_router)
|
||||
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
device_manager = get_device_manager()
|
||||
task_executor = get_task_executor()
|
||||
ws_manager = get_ws_manager()
|
||||
|
||||
devices = device_manager.list_all_devices()
|
||||
connected_devices = sum(1 for d in devices if d.is_connected)
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"devices": {
|
||||
"total": len(devices),
|
||||
"connected": connected_devices,
|
||||
},
|
||||
"tasks": {
|
||||
"active": task_executor.get_active_task_count(),
|
||||
},
|
||||
"websocket": {
|
||||
"connections": ws_manager.get_connection_count(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Root endpoint - serve dashboard or API info
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint - returns API info or serves dashboard."""
|
||||
# Check if static files exist
|
||||
static_path = Path(__file__).parent / "static" / "index.html"
|
||||
|
||||
if static_path.exists():
|
||||
return FileResponse(static_path)
|
||||
|
||||
# Return API info if dashboard not built
|
||||
return {
|
||||
"name": "AutoGLM Dashboard API",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"endpoints": {
|
||||
"devices": "/api/devices",
|
||||
"tasks": "/api/tasks",
|
||||
"websocket": "/ws",
|
||||
"health": "/health",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Mount static files for dashboard (if exists)
|
||||
static_path = Path(__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
||||
|
||||
|
||||
# Run script entry point
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"dashboard.main:app",
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
reload=config.DEBUG,
|
||||
)
|
||||
37
dashboard/models/__init__.py
Normal file
37
dashboard/models/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Data models for the dashboard API.
|
||||
|
||||
Includes Pydantic schemas for devices, tasks, and WebSocket messages.
|
||||
"""
|
||||
|
||||
from dashboard.models.device import (
|
||||
DeviceType,
|
||||
DeviceStatus,
|
||||
DeviceSchema,
|
||||
DeviceInfo,
|
||||
)
|
||||
from dashboard.models.task import (
|
||||
TaskStatus,
|
||||
TaskRequest,
|
||||
TaskSchema,
|
||||
TaskCreateRequest,
|
||||
)
|
||||
from dashboard.models.ws_messages import (
|
||||
WSMessageType,
|
||||
WSMessage,
|
||||
StepUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceType",
|
||||
"DeviceStatus",
|
||||
"DeviceSchema",
|
||||
"DeviceInfo",
|
||||
"TaskStatus",
|
||||
"TaskRequest",
|
||||
"TaskSchema",
|
||||
"TaskCreateRequest",
|
||||
"WSMessageType",
|
||||
"WSMessage",
|
||||
"StepUpdate",
|
||||
]
|
||||
67
dashboard/models/device.py
Normal file
67
dashboard/models/device.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Device data models for the dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeviceType(str, Enum):
|
||||
"""Device connection type."""
|
||||
|
||||
ADB = "adb"
|
||||
HDC = "hdc"
|
||||
IOS = "ios"
|
||||
|
||||
|
||||
class DeviceStatus(str, Enum):
|
||||
"""Device status."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
BUSY = "busy"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class DeviceSchema(BaseModel):
|
||||
"""Device schema for API responses."""
|
||||
|
||||
device_id: str = Field(..., description="Unique device identifier")
|
||||
status: DeviceStatus = Field(default=DeviceStatus.OFFLINE, description="Device status")
|
||||
device_type: DeviceType = Field(..., description="Device connection type")
|
||||
model: Optional[str] = Field(None, description="Device model name")
|
||||
android_version: Optional[str] = Field(None, description="Android/iOS version")
|
||||
current_app: Optional[str] = Field(None, description="Currently active app")
|
||||
last_seen: datetime = Field(default_factory=datetime.now, description="Last connection time")
|
||||
is_connected: bool = Field(True, description="Whether device is connected")
|
||||
screenshot: Optional[str] = Field(None, description="Base64 encoded screenshot")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"device_id": "emulator-5554",
|
||||
"status": "online",
|
||||
"device_type": "adb",
|
||||
"model": "sdk_gphone64_x86_64",
|
||||
"android_version": "14",
|
||||
"current_app": "com.android.launcher3",
|
||||
"is_connected": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Extended device information."""
|
||||
|
||||
device_id: str
|
||||
status: DeviceStatus
|
||||
device_type: DeviceType
|
||||
model: Optional[str] = None
|
||||
android_version: Optional[str] = None
|
||||
current_app: Optional[str] = None
|
||||
last_seen: datetime
|
||||
screenshot: Optional[str] = None
|
||||
is_connected: bool = True
|
||||
81
dashboard/models/task.py
Normal file
81
dashboard/models/task.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Task data models for the dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from phone_agent.model import ModelConfig
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task execution status."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""Request to create a new task."""
|
||||
|
||||
device_id: str = Field(..., description="Target device ID")
|
||||
task: str = Field(..., description="Task description")
|
||||
max_steps: int = Field(100, description="Maximum execution steps")
|
||||
lang: str = Field("cn", description="Language (cn or en)")
|
||||
|
||||
# Model config - use dict to avoid validation issues with ModelConfig
|
||||
base_url: str = Field(
|
||||
default="http://localhost:8000/v1", description="Model API base URL"
|
||||
)
|
||||
model_name: str = Field(default="autoglm-phone-9b", description="Model name")
|
||||
api_key: str = Field(default="EMPTY", description="API key")
|
||||
max_tokens: int = Field(default=3000, description="Max tokens per response")
|
||||
temperature: float = Field(default=0.0, description="Sampling temperature")
|
||||
top_p: float = Field(default=0.85, description="Top-p sampling parameter")
|
||||
frequency_penalty: float = Field(default=0.2, description="Frequency penalty")
|
||||
|
||||
|
||||
class TaskSchema(BaseModel):
|
||||
"""Task schema for API responses."""
|
||||
|
||||
task_id: str = Field(..., description="Unique task identifier")
|
||||
device_id: str = Field(..., description="Target device ID")
|
||||
task: str = Field(..., description="Task description")
|
||||
status: TaskStatus = Field(..., description="Task status")
|
||||
current_step: int = Field(0, description="Current step number")
|
||||
max_steps: int = Field(100, description="Maximum steps")
|
||||
current_action: Optional[Dict[str, Any]] = Field(None, description="Current action")
|
||||
thinking: Optional[str] = Field(None, description="Current thinking/reasoning")
|
||||
started_at: datetime = Field(..., description="Task start time")
|
||||
updated_at: datetime = Field(..., description="Last update time")
|
||||
finished_at: Optional[datetime] = Field(None, description="Task completion time")
|
||||
error: Optional[str] = Field(None, description="Error message if failed")
|
||||
completion_message: Optional[str] = Field(
|
||||
None, description="Task completion message with details"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"task_id": "task_123456",
|
||||
"device_id": "emulator-5554",
|
||||
"task": "Open WeChat",
|
||||
"status": "running",
|
||||
"current_step": 3,
|
||||
"max_steps": 100,
|
||||
"current_action": {"action": "Tap", "element": "WeChat icon"},
|
||||
"thinking": "Looking for WeChat icon on home screen",
|
||||
"started_at": "2024-01-09T10:00:00",
|
||||
"updated_at": "2024-01-09T10:00:15",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
TaskRequest = TaskCreateRequest
|
||||
70
dashboard/models/ws_messages.py
Normal file
70
dashboard/models/ws_messages.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
WebSocket message models for real-time updates.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WSMessageType(str, Enum):
|
||||
"""WebSocket message types."""
|
||||
|
||||
DEVICE_UPDATE = "device_update"
|
||||
TASK_STARTED = "task_started"
|
||||
TASK_STEP = "task_step"
|
||||
TASK_COMPLETED = "task_completed"
|
||||
TASK_FAILED = "task_failed"
|
||||
TASK_STOPPED = "task_stopped"
|
||||
SCREENSHOT = "screenshot"
|
||||
ERROR = "error"
|
||||
PING = "ping"
|
||||
PONG = "pong"
|
||||
|
||||
|
||||
class WSMessage(BaseModel):
|
||||
"""Base WebSocket message."""
|
||||
|
||||
type: WSMessageType = Field(..., description="Message type")
|
||||
data: Dict[str, Any] = Field(default_factory=dict, description="Message data")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="Message timestamp")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"type": "task_step",
|
||||
"data": {
|
||||
"task_id": "task_123",
|
||||
"device_id": "emulator-5554",
|
||||
"step": 5,
|
||||
"action": {"action": "Tap"},
|
||||
"thinking": "Tapping on button",
|
||||
},
|
||||
"timestamp": "2024-01-09T10:00:00",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StepUpdate(BaseModel):
|
||||
"""Step update message data."""
|
||||
|
||||
task_id: str = Field(..., description="Task ID")
|
||||
device_id: str = Field(..., description="Device ID")
|
||||
step: int = Field(..., description="Step number")
|
||||
action: Optional[Dict[str, Any]] = Field(None, description="Action taken")
|
||||
thinking: Optional[str] = Field(None, description="AI reasoning")
|
||||
finished: bool = Field(False, description="Whether task is finished")
|
||||
success: bool = Field(True, description="Whether step succeeded")
|
||||
message: Optional[str] = Field(None, description="Status message")
|
||||
|
||||
|
||||
class ScreenshotUpdate(BaseModel):
|
||||
"""Screenshot update message data."""
|
||||
|
||||
device_id: str = Field(..., description="Device ID")
|
||||
screenshot: str = Field(..., description="Base64 encoded screenshot")
|
||||
width: int = Field(..., description="Screenshot width")
|
||||
height: int = Field(..., description="Screenshot height")
|
||||
timestamp: datetime = Field(default_factory=datetime.now)
|
||||
15
dashboard/services/__init__.py
Normal file
15
dashboard/services/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Services for the dashboard.
|
||||
|
||||
Includes device management, task execution, and WebSocket management.
|
||||
"""
|
||||
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from dashboard.services.task_executor import TaskExecutor
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
|
||||
__all__ = [
|
||||
"DeviceManager",
|
||||
"TaskExecutor",
|
||||
"WebSocketManager",
|
||||
]
|
||||
336
dashboard/services/device_manager.py
Normal file
336
dashboard/services/device_manager.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Device manager for handling device pool and connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from phone_agent.adb.connection import ADBConnection
|
||||
from phone_agent.adb.screenshot import get_screenshot as adb_screenshot
|
||||
from phone_agent.device_factory import (
|
||||
DeviceFactory,
|
||||
DeviceType,
|
||||
get_device_factory,
|
||||
set_device_type,
|
||||
)
|
||||
from phone_agent.hdc.connection import HDCConnection
|
||||
from phone_agent.hdc.screenshot import get_screenshot as hdc_screenshot
|
||||
|
||||
from dashboard.models.device import DeviceInfo, DeviceStatus, DeviceSchema
|
||||
|
||||
|
||||
class DeviceConnectionError(Exception):
|
||||
"""Raised when device connection fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""Manage device pool and connections."""
|
||||
|
||||
def __init__(self, device_type: str = "adb"):
|
||||
"""Initialize the device manager.
|
||||
|
||||
Args:
|
||||
device_type: Default device type (adb, hdc, ios)
|
||||
"""
|
||||
self._devices: Dict[str, DeviceInfo] = {}
|
||||
self._device_locks: Dict[str, Lock] = {}
|
||||
self._device_type = self._parse_device_type(device_type)
|
||||
self._screenshot_cache: Dict[str, tuple[str, datetime]] = {} # (base64, timestamp)
|
||||
self._cache_ttl_seconds = 2.0 # Cache screenshots for 2 seconds
|
||||
|
||||
# Set the global device type
|
||||
set_device_type(self._device_type)
|
||||
|
||||
def _parse_device_type(self, device_type: str) -> DeviceType:
|
||||
"""Parse device type string to enum."""
|
||||
try:
|
||||
return DeviceType[device_type.upper()]
|
||||
except KeyError:
|
||||
return DeviceType.ADB
|
||||
|
||||
@property
|
||||
def factory(self) -> DeviceFactory:
|
||||
"""Get the device factory instance."""
|
||||
return get_device_factory()
|
||||
|
||||
async def refresh_devices(self) -> List[DeviceInfo]:
|
||||
"""Scan and return all connected devices.
|
||||
|
||||
Returns:
|
||||
List of device info objects
|
||||
"""
|
||||
try:
|
||||
# Run device listing in thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
devices = await loop.run_in_executor(None, self.factory.list_devices)
|
||||
|
||||
# Update device cache
|
||||
current_time = datetime.now()
|
||||
for device in devices:
|
||||
device_id = device.device_id
|
||||
|
||||
# Initialize lock if needed
|
||||
if device_id not in self._device_locks:
|
||||
self._device_locks[device_id] = Lock()
|
||||
|
||||
# Update or create device info
|
||||
if device_id in self._devices:
|
||||
# Update existing device
|
||||
self._devices[device_id].last_seen = current_time
|
||||
self._devices[device_id].is_connected = True
|
||||
self._devices[device_id].status = (
|
||||
DeviceStatus.BUSY
|
||||
if not self._is_device_available(device_id)
|
||||
else DeviceStatus.ONLINE
|
||||
)
|
||||
else:
|
||||
# New device
|
||||
self._devices[device_id] = DeviceInfo(
|
||||
device_id=device_id,
|
||||
status=DeviceStatus.ONLINE,
|
||||
device_type=self._device_type_to_schema(device.connection_type),
|
||||
model=device.model,
|
||||
android_version=device.android_version,
|
||||
current_app=None,
|
||||
last_seen=current_time,
|
||||
is_connected=True,
|
||||
)
|
||||
|
||||
# Mark disconnected devices
|
||||
connected_ids = {d.device_id for d in devices}
|
||||
for device_id, device_info in self._devices.items():
|
||||
if device_id not in connected_ids:
|
||||
device_info.is_connected = False
|
||||
device_info.status = DeviceStatus.OFFLINE
|
||||
|
||||
return list(self._devices.values())
|
||||
|
||||
except Exception as e:
|
||||
raise DeviceConnectionError(f"Failed to refresh devices: {e}")
|
||||
|
||||
async def get_device(self, device_id: str) -> Optional[DeviceInfo]:
|
||||
"""Get device info by ID.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Device info or None if not found
|
||||
"""
|
||||
return self._devices.get(device_id)
|
||||
|
||||
def acquire_device(self, device_id: str) -> bool:
|
||||
"""Acquire lock on device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
True if acquired, False if device is busy
|
||||
"""
|
||||
if device_id not in self._device_locks:
|
||||
return False
|
||||
|
||||
lock = self._device_locks[device_id]
|
||||
acquired = lock.acquire(blocking=False)
|
||||
|
||||
if acquired:
|
||||
# Update device status
|
||||
if device_id in self._devices:
|
||||
self._devices[device_id].status = DeviceStatus.BUSY
|
||||
|
||||
return acquired
|
||||
|
||||
def release_device(self, device_id: str):
|
||||
"""Release device lock.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
"""
|
||||
if device_id in self._device_locks:
|
||||
self._device_locks[device_id].release()
|
||||
|
||||
# Update device status
|
||||
if device_id in self._devices:
|
||||
device = self._devices[device_id]
|
||||
if device.is_connected:
|
||||
device.status = DeviceStatus.ONLINE
|
||||
else:
|
||||
device.status = DeviceStatus.OFFLINE
|
||||
|
||||
def is_device_available(self, device_id: str) -> bool:
|
||||
"""Check if device is available for task execution.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
True if available, False if busy or offline
|
||||
"""
|
||||
if device_id not in self._devices:
|
||||
return False
|
||||
|
||||
device = self._devices[device_id]
|
||||
if not device.is_connected:
|
||||
return False
|
||||
|
||||
if device.status == DeviceStatus.BUSY:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_device_available(self, device_id: str) -> bool:
|
||||
"""Internal check if device lock is available."""
|
||||
if device_id not in self._device_locks:
|
||||
return True
|
||||
lock = self._device_locks[device_id]
|
||||
return not lock.locked()
|
||||
|
||||
async def get_screenshot(self, device_id: str) -> Optional[str]:
|
||||
"""Get screenshot for device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Base64 encoded screenshot or None
|
||||
"""
|
||||
# Check cache
|
||||
if device_id in self._screenshot_cache:
|
||||
screenshot, timestamp = self._screenshot_cache[device_id]
|
||||
age = (datetime.now() - timestamp).total_seconds()
|
||||
if age < self._cache_ttl_seconds:
|
||||
return screenshot
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if self._device_type == DeviceType.HDC:
|
||||
result = await loop.run_in_executor(
|
||||
None, hdc_screenshot, device_id, 10
|
||||
)
|
||||
else: # ADB or default
|
||||
result = await loop.run_in_executor(
|
||||
None, adb_screenshot, device_id, 10
|
||||
)
|
||||
|
||||
if result:
|
||||
# Cache the screenshot
|
||||
self._screenshot_cache[device_id] = (result.base64_data, datetime.now())
|
||||
return result.base64_data
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def get_current_app(self, device_id: str) -> Optional[str]:
|
||||
"""Get current app for device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Current app package name or None
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
app = await loop.run_in_executor(
|
||||
None, self.factory.get_current_app, device_id
|
||||
)
|
||||
|
||||
# Update device info
|
||||
if device_id in self._devices and app:
|
||||
self._devices[device_id].current_app = app
|
||||
|
||||
return app
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def connect_device(self, address: str) -> bool:
|
||||
"""Connect to device via WiFi.
|
||||
|
||||
Args:
|
||||
address: Device address (IP:PORT)
|
||||
|
||||
Returns:
|
||||
True if connected successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if self._device_type == DeviceType.HDC:
|
||||
conn = HDCConnection()
|
||||
else:
|
||||
conn = ADBConnection()
|
||||
|
||||
success, _ = await loop.run_in_executor(None, conn.connect, address)
|
||||
|
||||
if success:
|
||||
# Refresh devices after connecting
|
||||
await self.refresh_devices()
|
||||
|
||||
return success
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def disconnect_device(self, address: str) -> bool:
|
||||
"""Disconnect from device.
|
||||
|
||||
Args:
|
||||
address: Device address (IP:PORT)
|
||||
|
||||
Returns:
|
||||
True if disconnected successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if self._device_type == DeviceType.HDC:
|
||||
conn = HDCConnection()
|
||||
else:
|
||||
conn = ADBConnection()
|
||||
|
||||
success, _ = await loop.run_in_executor(None, conn.disconnect, address)
|
||||
|
||||
if success:
|
||||
# Refresh devices after disconnecting
|
||||
await self.refresh_devices()
|
||||
|
||||
return success
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _device_type_to_schema(self, connection_type) -> "DeviceSchema":
|
||||
"""Convert connection type to DeviceType enum."""
|
||||
from dashboard.models.device import DeviceType as SchemaDeviceType
|
||||
|
||||
# Mapping from phone_agent connection types to schema types
|
||||
type_map = {
|
||||
"USB": SchemaDeviceType.ADB,
|
||||
"WIFI": SchemaDeviceType.ADB,
|
||||
"REMOTE": SchemaDeviceType.ADB,
|
||||
}
|
||||
|
||||
# Try to get string value from enum
|
||||
try:
|
||||
type_str = connection_type.value if hasattr(connection_type, "value") else str(connection_type)
|
||||
return type_map.get(type_str.upper(), SchemaDeviceType.ADB)
|
||||
except (AttributeError, KeyError):
|
||||
return SchemaDeviceType.ADB
|
||||
|
||||
def list_all_devices(self) -> List[DeviceInfo]:
|
||||
"""Get all cached devices.
|
||||
|
||||
Returns:
|
||||
List of all device info
|
||||
"""
|
||||
return list(self._devices.values())
|
||||
410
dashboard/services/task_executor.py
Normal file
410
dashboard/services/task_executor.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Task executor for running tasks on devices with thread pool.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from phone_agent import AgentConfig, PhoneAgent
|
||||
from phone_agent.agent import StepResult
|
||||
from phone_agent.model import ModelConfig
|
||||
|
||||
from dashboard.config import config
|
||||
from dashboard.models.task import TaskCreateRequest, TaskSchema, TaskStatus
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Run async coroutine from sync context and wait for completion."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're already in an async context, use run_coroutine_threadsafe
|
||||
import concurrent.futures
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
# Wait for completion with timeout to avoid hanging
|
||||
try:
|
||||
future.result(timeout=5)
|
||||
except concurrent.futures.TimeoutError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except RuntimeError:
|
||||
# No running loop, use asyncio.run
|
||||
asyncio.run(coro)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveTask:
|
||||
"""An active task being executed."""
|
||||
|
||||
task_id: str
|
||||
device_id: str
|
||||
task: str
|
||||
status: TaskStatus
|
||||
current_step: int
|
||||
max_steps: int
|
||||
started_at: datetime
|
||||
updated_at: datetime
|
||||
finished_at: Optional[datetime] = None
|
||||
future: Optional[Future] = None
|
||||
stop_requested: bool = False
|
||||
current_action: Optional[dict] = None
|
||||
thinking: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
completion_message: Optional[str] = None
|
||||
|
||||
|
||||
class TaskExecutor:
|
||||
"""Execute tasks on devices with thread pool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_manager: DeviceManager,
|
||||
ws_manager: Optional[WebSocketManager] = None,
|
||||
max_workers: int = 10,
|
||||
):
|
||||
"""Initialize the task executor.
|
||||
|
||||
Args:
|
||||
device_manager: Device manager instance
|
||||
ws_manager: WebSocket manager for real-time updates (optional)
|
||||
max_workers: Maximum number of concurrent tasks
|
||||
"""
|
||||
self.device_manager = device_manager
|
||||
self.ws_manager = ws_manager
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self.active_tasks: Dict[str, ActiveTask] = {}
|
||||
self.task_history: Dict[str, TaskSchema] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_ws_manager(self, ws_manager: WebSocketManager):
|
||||
"""Set the WebSocket manager.
|
||||
|
||||
Args:
|
||||
ws_manager: WebSocket manager instance
|
||||
"""
|
||||
self.ws_manager = ws_manager
|
||||
|
||||
async def execute_task(self, request: TaskCreateRequest) -> str:
|
||||
"""Execute task on device.
|
||||
|
||||
Args:
|
||||
request: Task creation request
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
task_id = f"task_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create active task
|
||||
active_task = ActiveTask(
|
||||
task_id=task_id,
|
||||
device_id=request.device_id,
|
||||
task=request.task,
|
||||
status=TaskStatus.RUNNING,
|
||||
current_step=0,
|
||||
max_steps=request.max_steps,
|
||||
started_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self.active_tasks[task_id] = active_task
|
||||
|
||||
# Notify WebSocket
|
||||
if self.ws_manager:
|
||||
await self.ws_manager.broadcast_task_started(task_id, request.dict())
|
||||
|
||||
# Submit to thread pool
|
||||
future = self.executor.submit(
|
||||
self._run_task,
|
||||
task_id,
|
||||
request,
|
||||
)
|
||||
active_task.future = future
|
||||
|
||||
return task_id
|
||||
|
||||
def _run_task(self, task_id: str, request: TaskCreateRequest):
|
||||
"""Run task in thread pool.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
request: Task creation request
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get model config from request
|
||||
model_config = ModelConfig(
|
||||
base_url=request.base_url,
|
||||
model_name=request.model_name,
|
||||
api_key=request.api_key,
|
||||
max_tokens=request.max_tokens,
|
||||
temperature=request.temperature,
|
||||
top_p=request.top_p,
|
||||
frequency_penalty=request.frequency_penalty,
|
||||
lang=request.lang,
|
||||
)
|
||||
|
||||
# Get agent config
|
||||
agent_config = AgentConfig(
|
||||
max_steps=request.max_steps,
|
||||
device_id=request.device_id,
|
||||
lang=request.lang,
|
||||
step_callback=lambda result: self._step_callback(task_id, result),
|
||||
before_action_callback=lambda action: self._before_action_callback(
|
||||
task_id, action
|
||||
),
|
||||
)
|
||||
|
||||
# Create agent
|
||||
agent = PhoneAgent(model_config=model_config, agent_config=agent_config)
|
||||
|
||||
try:
|
||||
# Acquire device
|
||||
if not self.device_manager.acquire_device(request.device_id):
|
||||
raise Exception(f"Device {request.device_id} is not available")
|
||||
|
||||
try:
|
||||
# Run task
|
||||
result = agent.run(request.task)
|
||||
|
||||
# Update task status
|
||||
with self._lock:
|
||||
if task_id in self.active_tasks:
|
||||
task = self.active_tasks[task_id]
|
||||
task.status = (
|
||||
TaskStatus.COMPLETED if result else TaskStatus.FAILED
|
||||
)
|
||||
task.finished_at = datetime.now()
|
||||
task.updated_at = datetime.now()
|
||||
|
||||
finally:
|
||||
# Release device
|
||||
self.device_manager.release_device(request.device_id)
|
||||
|
||||
# Broadcast device status update
|
||||
if self.ws_manager:
|
||||
device = self.device_manager._devices.get(request.device_id)
|
||||
if device:
|
||||
_run_async(
|
||||
self.ws_manager.broadcast_device_update(
|
||||
request.device_id,
|
||||
{
|
||||
"status": device.status.value,
|
||||
"is_connected": device.is_connected,
|
||||
"current_app": device.current_app,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Notify completion
|
||||
if self.ws_manager:
|
||||
with self._lock:
|
||||
task = self.active_tasks.get(task_id)
|
||||
task_status = task.status if task else TaskStatus.COMPLETED
|
||||
message = result.message if hasattr(result, "message") else None
|
||||
|
||||
_run_async(
|
||||
self.ws_manager.broadcast_task_completed(
|
||||
task_id,
|
||||
request.device_id,
|
||||
task_status,
|
||||
message,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Release device on error
|
||||
self.device_manager.release_device(request.device_id)
|
||||
|
||||
# Broadcast device status update
|
||||
if self.ws_manager:
|
||||
device = self.device_manager._devices.get(request.device_id)
|
||||
if device:
|
||||
_run_async(
|
||||
self.ws_manager.broadcast_device_update(
|
||||
request.device_id,
|
||||
{
|
||||
"status": device.status.value,
|
||||
"is_connected": device.is_connected,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Update task status
|
||||
with self._lock:
|
||||
if task_id in self.active_tasks:
|
||||
task = self.active_tasks[task_id]
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = str(e)
|
||||
task.finished_at = datetime.now()
|
||||
task.updated_at = datetime.now()
|
||||
|
||||
# Notify error
|
||||
if self.ws_manager:
|
||||
_run_async(
|
||||
self.ws_manager.broadcast_task_failed(
|
||||
task_id, request.device_id, str(e)
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
# Move to history
|
||||
with self._lock:
|
||||
if task_id in self.active_tasks:
|
||||
active_task = self.active_tasks.pop(task_id)
|
||||
task_schema = self._active_task_to_schema(active_task)
|
||||
self.task_history[task_id] = task_schema
|
||||
|
||||
# Trim history
|
||||
if len(self.task_history) > config.MAX_TASK_HISTORY:
|
||||
oldest = min(self.task_history.items(), key=lambda x: x[1].started_at)
|
||||
del self.task_history[oldest[0]]
|
||||
|
||||
def _step_callback(
|
||||
self, task_id: str, result: StepResult
|
||||
) -> Optional[str]:
|
||||
"""Callback after each step.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
result: Step result
|
||||
|
||||
Returns:
|
||||
"stop" to interrupt, new task to switch, None to continue
|
||||
"""
|
||||
with self._lock:
|
||||
if task_id not in self.active_tasks:
|
||||
return None
|
||||
|
||||
task = self.active_tasks[task_id]
|
||||
|
||||
# Check if stop was requested
|
||||
if task.stop_requested:
|
||||
return "stop"
|
||||
|
||||
# Update task state
|
||||
task.current_step = result.step_count
|
||||
task.updated_at = datetime.now()
|
||||
task.thinking = result.thinking
|
||||
task.current_action = result.action
|
||||
|
||||
# Store completion message when finished
|
||||
if result.finished and result.message:
|
||||
task.completion_message = result.message
|
||||
|
||||
# Notify WebSocket
|
||||
if self.ws_manager:
|
||||
_run_async(
|
||||
self.ws_manager.broadcast_step_update(
|
||||
task_id=task_id,
|
||||
device_id=task.device_id,
|
||||
step=result.step_count,
|
||||
action=result.action,
|
||||
thinking=result.thinking,
|
||||
finished=result.finished,
|
||||
success=result.success,
|
||||
message=result.message,
|
||||
)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _before_action_callback(self, task_id: str, action: dict) -> Optional[dict]:
|
||||
"""Callback before executing action.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
action: Action to execute
|
||||
|
||||
Returns:
|
||||
Modified action or None to proceed as-is
|
||||
"""
|
||||
# Can be used for action validation/logging
|
||||
return None
|
||||
|
||||
async def stop_task(self, task_id: str):
|
||||
"""Stop running task.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
"""
|
||||
with self._lock:
|
||||
if task_id not in self.active_tasks:
|
||||
return
|
||||
|
||||
task = self.active_tasks[task_id]
|
||||
task.stop_requested = True
|
||||
|
||||
async def get_task_status(self, task_id: str) -> Optional[TaskSchema]:
|
||||
"""Get task status.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
Task schema or None if not found
|
||||
"""
|
||||
with self._lock:
|
||||
if task_id in self.active_tasks:
|
||||
return self._active_task_to_schema(self.active_tasks[task_id])
|
||||
elif task_id in self.task_history:
|
||||
return self.task_history[task_id]
|
||||
|
||||
return None
|
||||
|
||||
async def list_tasks(self) -> list[TaskSchema]:
|
||||
"""List all tasks (active and recent).
|
||||
|
||||
Returns:
|
||||
List of task schemas
|
||||
"""
|
||||
with self._lock:
|
||||
active_schemas = [
|
||||
self._active_task_to_schema(t) for t in self.active_tasks.values()
|
||||
]
|
||||
history_schemas = list(self.task_history.values())
|
||||
|
||||
return active_schemas + history_schemas
|
||||
|
||||
def _active_task_to_schema(self, active_task: ActiveTask) -> TaskSchema:
|
||||
"""Convert ActiveTask to TaskSchema.
|
||||
|
||||
Args:
|
||||
active_task: Active task
|
||||
|
||||
Returns:
|
||||
Task schema
|
||||
"""
|
||||
return TaskSchema(
|
||||
task_id=active_task.task_id,
|
||||
device_id=active_task.device_id,
|
||||
task=active_task.task,
|
||||
status=active_task.status,
|
||||
current_step=active_task.current_step,
|
||||
max_steps=active_task.max_steps,
|
||||
current_action=active_task.current_action,
|
||||
thinking=active_task.thinking,
|
||||
started_at=active_task.started_at,
|
||||
updated_at=active_task.updated_at,
|
||||
finished_at=active_task.finished_at,
|
||||
error=active_task.error,
|
||||
completion_message=active_task.completion_message,
|
||||
)
|
||||
|
||||
def get_active_task_count(self) -> int:
|
||||
"""Get number of active tasks.
|
||||
|
||||
Returns:
|
||||
Active task count
|
||||
"""
|
||||
with self._lock:
|
||||
return len(self.active_tasks)
|
||||
338
dashboard/services/websocket_manager.py
Normal file
338
dashboard/services/websocket_manager.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
WebSocket manager for real-time updates.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from dashboard.models.ws_messages import (
|
||||
ScreenshotUpdate,
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manage WebSocket connections for real-time updates."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the WebSocket manager."""
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
self.client_subscriptions: Dict[str, set[str]] = {} # client_id -> set of device_ids
|
||||
|
||||
async def connect(self, websocket: WebSocket, client_id: Optional[str] = None) -> str:
|
||||
"""Accept and store connection.
|
||||
|
||||
Args:
|
||||
websocket: WebSocket connection
|
||||
client_id: Optional client ID (auto-generated if not provided)
|
||||
|
||||
Returns:
|
||||
Client ID
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
if client_id is None:
|
||||
client_id = f"client_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
self.active_connections[client_id] = websocket
|
||||
self.client_subscriptions[client_id] = set()
|
||||
|
||||
return client_id
|
||||
|
||||
def disconnect(self, client_id: str):
|
||||
"""Remove connection.
|
||||
|
||||
Args:
|
||||
client_id: Client ID
|
||||
"""
|
||||
if client_id in self.active_connections:
|
||||
del self.active_connections[client_id]
|
||||
|
||||
if client_id in self.client_subscriptions:
|
||||
del self.client_subscriptions[client_id]
|
||||
|
||||
async def send_to_client(
|
||||
self, client_id: str, message: WSMessage | Dict[str, Any]
|
||||
):
|
||||
"""Send message to specific client.
|
||||
|
||||
Args:
|
||||
client_id: Client ID
|
||||
message: Message to send
|
||||
"""
|
||||
if client_id not in self.active_connections:
|
||||
return
|
||||
|
||||
websocket = self.active_connections[client_id]
|
||||
|
||||
# Convert dict to WSMessage if needed
|
||||
if isinstance(message, dict):
|
||||
message = WSMessage(
|
||||
type=WSMessageType(message.get("type", "error")),
|
||||
data=message.get("data", {}),
|
||||
timestamp=message.get("timestamp", datetime.now()),
|
||||
)
|
||||
|
||||
try:
|
||||
await websocket.send_json(message.model_dump(mode="json"))
|
||||
except Exception:
|
||||
# Connection may be closed
|
||||
self.disconnect(client_id)
|
||||
|
||||
async def broadcast(self, message: WSMessage | Dict[str, Any]):
|
||||
"""Broadcast message to all connected clients.
|
||||
|
||||
Args:
|
||||
message: Message to broadcast
|
||||
"""
|
||||
# Convert dict to WSMessage if needed
|
||||
if isinstance(message, dict):
|
||||
message = WSMessage(
|
||||
type=WSMessageType(message.get("type", "error")),
|
||||
data=message.get("data", {}),
|
||||
timestamp=message.get("timestamp", datetime.now()),
|
||||
)
|
||||
|
||||
# Create list of clients to avoid modification during iteration
|
||||
clients = list(self.active_connections.items())
|
||||
|
||||
for client_id, websocket in clients:
|
||||
try:
|
||||
await websocket.send_json(message.model_dump(mode="json"))
|
||||
except Exception:
|
||||
self.disconnect(client_id)
|
||||
|
||||
async def broadcast_to_device_subscribers(
|
||||
self, device_id: str, message: WSMessage | Dict[str, Any]
|
||||
):
|
||||
"""Broadcast message to clients subscribed to a device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
message: Message to broadcast
|
||||
"""
|
||||
# Convert dict to WSMessage if needed
|
||||
if isinstance(message, dict):
|
||||
message = WSMessage(
|
||||
type=WSMessageType(message.get("type", "error")),
|
||||
data=message.get("data", {}),
|
||||
timestamp=message.get("timestamp", datetime.now()),
|
||||
)
|
||||
|
||||
# Find clients subscribed to this device
|
||||
for client_id, subscriptions in self.client_subscriptions.items():
|
||||
if device_id in subscriptions or "*" in subscriptions:
|
||||
await self.send_to_client(client_id, message)
|
||||
|
||||
def subscribe_to_device(self, client_id: str, device_id: str):
|
||||
"""Subscribe client to device updates.
|
||||
|
||||
Args:
|
||||
client_id: Client ID
|
||||
device_id: Device ID (use "*" for all devices)
|
||||
"""
|
||||
if client_id in self.client_subscriptions:
|
||||
self.client_subscriptions[client_id].add(device_id)
|
||||
|
||||
def unsubscribe_from_device(self, client_id: str, device_id: str):
|
||||
"""Unsubscribe client from device updates.
|
||||
|
||||
Args:
|
||||
client_id: Client ID
|
||||
device_id: Device ID
|
||||
"""
|
||||
if client_id in self.client_subscriptions:
|
||||
self.client_subscriptions[client_id].discard(device_id)
|
||||
|
||||
# Convenience methods for specific message types
|
||||
|
||||
async def broadcast_device_update(self, device_id: str, device_data: Dict[str, Any]):
|
||||
"""Broadcast device update.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
device_data: Device data
|
||||
"""
|
||||
await self.broadcast_to_device_subscribers(
|
||||
device_id,
|
||||
WSMessage(
|
||||
type=WSMessageType.DEVICE_UPDATE,
|
||||
data={"device_id": device_id, **device_data},
|
||||
),
|
||||
)
|
||||
|
||||
async def broadcast_task_started(self, task_id: str, task_data: Dict[str, Any]):
|
||||
"""Broadcast task started.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
task_data: Task data
|
||||
"""
|
||||
await self.broadcast(
|
||||
WSMessage(
|
||||
type=WSMessageType.TASK_STARTED,
|
||||
data={"task_id": task_id, **task_data},
|
||||
)
|
||||
)
|
||||
|
||||
async def broadcast_step_update(
|
||||
self,
|
||||
task_id: str,
|
||||
device_id: str,
|
||||
step: int,
|
||||
action: Optional[Dict] = None,
|
||||
thinking: Optional[str] = None,
|
||||
finished: bool = False,
|
||||
success: bool = True,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
"""Broadcast task step update.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
device_id: Device ID
|
||||
step: Step number
|
||||
action: Current action
|
||||
thinking: AI reasoning
|
||||
finished: Whether task is finished
|
||||
success: Whether step succeeded
|
||||
message: Status message
|
||||
"""
|
||||
await self.broadcast_to_device_subscribers(
|
||||
device_id,
|
||||
WSMessage(
|
||||
type=WSMessageType.TASK_STEP,
|
||||
data={
|
||||
"task_id": task_id,
|
||||
"device_id": device_id,
|
||||
"step": step,
|
||||
"action": action,
|
||||
"thinking": thinking,
|
||||
"finished": finished,
|
||||
"success": success,
|
||||
"message": message,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def broadcast_task_completed(
|
||||
self, task_id: str, device_id: str, status: str, message: Optional[str] = None
|
||||
):
|
||||
"""Broadcast task completed.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
device_id: Device ID
|
||||
status: Task status
|
||||
message: Completion message
|
||||
"""
|
||||
await self.broadcast(
|
||||
WSMessage(
|
||||
type=WSMessageType.TASK_COMPLETED,
|
||||
data={
|
||||
"task_id": task_id,
|
||||
"device_id": device_id,
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def broadcast_task_failed(self, task_id: str, device_id: str, error: str):
|
||||
"""Broadcast task failed.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
device_id: Device ID
|
||||
error: Error message
|
||||
"""
|
||||
await self.broadcast(
|
||||
WSMessage(
|
||||
type=WSMessageType.TASK_FAILED,
|
||||
data={
|
||||
"task_id": task_id,
|
||||
"device_id": device_id,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def broadcast_task_stopped(self, task_id: str, device_id: str):
|
||||
"""Broadcast task stopped.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
device_id: Device ID
|
||||
"""
|
||||
await self.broadcast(
|
||||
WSMessage(
|
||||
type=WSMessageType.TASK_STOPPED,
|
||||
data={
|
||||
"task_id": task_id,
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def broadcast_screenshot(
|
||||
self, device_id: str, screenshot: str, width: int, height: int
|
||||
):
|
||||
"""Broadcast screenshot update.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
screenshot: Base64 encoded screenshot
|
||||
width: Image width
|
||||
height: Image height
|
||||
"""
|
||||
await self.broadcast_to_device_subscribers(
|
||||
device_id,
|
||||
WSMessage(
|
||||
type=WSMessageType.SCREENSHOT,
|
||||
data={
|
||||
"device_id": device_id,
|
||||
"screenshot": screenshot,
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def broadcast_error(self, error: str, details: Optional[Dict] = None):
|
||||
"""Broadcast error.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
details: Additional error details
|
||||
"""
|
||||
await self.broadcast(
|
||||
WSMessage(
|
||||
type=WSMessageType.ERROR,
|
||||
data={
|
||||
"error": error,
|
||||
"details": details,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get number of active connections.
|
||||
|
||||
Returns:
|
||||
Connection count
|
||||
"""
|
||||
return len(self.active_connections)
|
||||
|
||||
def get_client_ids(self) -> list[str]:
|
||||
"""Get list of connected client IDs.
|
||||
|
||||
Returns:
|
||||
List of client IDs
|
||||
"""
|
||||
return list(self.active_connections.keys())
|
||||
539
dashboard/static/css/dashboard.css
Normal file
539
dashboard/static/css/dashboard.css
Normal file
@@ -0,0 +1,539 @@
|
||||
/* AutoGLM Dashboard Styles */
|
||||
|
||||
:root {
|
||||
--primary-color: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--bg-color: #0f172a;
|
||||
--card-bg: #1e293b;
|
||||
--border-color: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background-color: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.ws-status.connected {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Device Grid */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.device-card.busy {
|
||||
border-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.device-card.offline {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.device-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.status-badge.busy {
|
||||
background-color: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.device-info p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-info p svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-actions input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.device-actions input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.device-actions input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Task List */
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.task-item.active {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-status.running {
|
||||
background-color: rgba(99, 102, 241, 0.2);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.task-status.completed {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.task-status.failed {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.task-status.stopped {
|
||||
background-color: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
height: 4px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.task-thinking {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-thinking svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.task-action svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-action strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-message {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-message svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-message.completed {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.task-message.failed {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.task-message.stopped {
|
||||
background-color: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.device-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
219
dashboard/static/index.html
Normal file
219
dashboard/static/index.html
Normal file
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AutoGLM Dashboard</title>
|
||||
<!-- Vue.js 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- Axios for API requests -->
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>AutoGLM Dashboard</h1>
|
||||
<div class="stats">
|
||||
<span class="stat" title="Connected Devices">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
|
||||
<line x1="12" y1="18" x2="12" y2="18"></line>
|
||||
</svg>
|
||||
{{ connectedDeviceCount }} / {{ devices.length }}
|
||||
</span>
|
||||
<span class="stat" title="Active Tasks">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
{{ activeTasks.length }}
|
||||
</span>
|
||||
<span class="stat ws-status" :class="{ connected: wsConnected }" title="WebSocket">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button @click="refreshDevices" class="btn btn-secondary" :disabled="refreshing">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: refreshing }">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Device Grid -->
|
||||
<section class="devices-section">
|
||||
<h2>Devices</h2>
|
||||
<div class="device-grid" v-if="devices.length > 0">
|
||||
<div
|
||||
class="device-card"
|
||||
v-for="device in devices"
|
||||
:key="device.device_id"
|
||||
:class="{ busy: device.status === 'busy', offline: !device.is_connected }"
|
||||
>
|
||||
<div class="device-header">
|
||||
<h3>{{ device.device_id }}</h3>
|
||||
<span class="status-badge" :class="device.status">
|
||||
{{ device.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<p v-if="device.model">{{ device.model }}</p>
|
||||
<p v-if="device.android_version" class="version">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
{{ device.android_version }}
|
||||
</p>
|
||||
<p v-if="device.current_app" class="app">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
</svg>
|
||||
{{ formatAppName(device.current_app) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="screenshot" v-if="device.screenshot">
|
||||
<img :src="'data:image/png;base64,' + device.screenshot" alt="Screen">
|
||||
</div>
|
||||
<div class="device-actions">
|
||||
<input
|
||||
v-model="device.taskInput"
|
||||
type="text"
|
||||
placeholder="Enter task..."
|
||||
@keyup.enter="executeTask(device)"
|
||||
:disabled="device.status === 'busy' || !device.is_connected"
|
||||
>
|
||||
<button
|
||||
@click="executeTask(device)"
|
||||
class="btn btn-primary"
|
||||
:disabled="device.status === 'busy' || !device.is_connected || !device.taskInput"
|
||||
>
|
||||
{{ device.status === 'busy' ? 'Running...' : 'Execute' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
|
||||
<line x1="12" y1="18" x2="12" y2="18"></line>
|
||||
</svg>
|
||||
<p>No devices found</p>
|
||||
<button @click="refreshDevices" class="btn btn-secondary">Scan for Devices</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task Queue -->
|
||||
<section class="tasks-section">
|
||||
<h2>Task Queue</h2>
|
||||
<div class="task-list" v-if="tasks.length > 0">
|
||||
<div
|
||||
class="task-item"
|
||||
v-for="task in tasks"
|
||||
:key="task.task_id"
|
||||
:class="{ active: task.status === 'running' }"
|
||||
>
|
||||
<div class="task-info">
|
||||
<div class="task-header">
|
||||
<span class="task-id">{{ task.task_id }}</span>
|
||||
<span class="task-status" :class="task.status">{{ task.status }}</span>
|
||||
</div>
|
||||
<p class="task-description">{{ task.task }}</p>
|
||||
<div class="task-meta">
|
||||
<span>Device: {{ task.device_id }}</span>
|
||||
<span>Step: {{ task.current_step }}/{{ task.max_steps }}</span>
|
||||
</div>
|
||||
<div class="task-progress" v-if="task.status === 'running'">
|
||||
<div class="progress-bar" :style="{ width: (task.current_step / task.max_steps * 100) + '%' }"></div>
|
||||
</div>
|
||||
<!-- Current Action -->
|
||||
<div class="task-action" v-if="task.current_action && task.status === 'running'">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
<strong>{{ task.current_action.action }}</strong>
|
||||
<span v-if="task.current_action.element"> on {{ task.current_action.element }}</span>
|
||||
<span v-if="task.current_action.text">: "{{ task.current_action.text }}"</span>
|
||||
</div>
|
||||
<!-- Thinking -->
|
||||
<div class="task-thinking" v-if="task.thinking">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
{{ task.thinking }}
|
||||
</div>
|
||||
<!-- Completion Message -->
|
||||
<div class="task-message" v-if="task.completion_message && (task.status === 'completed' || task.status === 'failed' || task.status === 'stopped')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="task.status === 'completed'" d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline v-if="task.status === 'completed'" points="22 4 12 14.01 9 11.01"></polyline>
|
||||
<circle v-if="task.status === 'failed'" cx="12" cy="12" r="10"></circle>
|
||||
<line v-if="task.status === 'failed'" x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line v-if="task.status === 'failed'" x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
{{ task.completion_message }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions" v-if="task.status === 'running'">
|
||||
<button @click="stopTask(task)" class="btn btn-danger btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="6" width="12" height="12"></rect>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<div class="task-actions" v-else>
|
||||
<button @click="reExecuteTask(task)" class="btn btn-secondary btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
Re-execute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
<p>No tasks yet</p>
|
||||
<p class="hint">Enter a task above to get started</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container">
|
||||
<div
|
||||
class="toast"
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="toast.type"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
404
dashboard/static/js/dashboard.js
Normal file
404
dashboard/static/js/dashboard.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* AutoGLM Dashboard - Vue.js Application
|
||||
*/
|
||||
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
devices: [],
|
||||
tasks: [],
|
||||
ws: null,
|
||||
wsConnected: false,
|
||||
refreshing: false,
|
||||
toasts: [],
|
||||
toastIdCounter: 0,
|
||||
reconnectAttempts: 0,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectDelay: 2000,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
activeTasks() {
|
||||
return this.tasks.filter(t => t.status === 'running');
|
||||
},
|
||||
|
||||
connectedDeviceCount() {
|
||||
return this.devices.filter(d => d.is_connected).length;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/* API Methods */
|
||||
async loadDevices() {
|
||||
try {
|
||||
const response = await axios.get('/api/devices');
|
||||
this.devices = response.data.map(d => ({
|
||||
...d,
|
||||
taskInput: '',
|
||||
screenshot: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.showToast('Failed to load devices', 'error');
|
||||
console.error('Error loading devices:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async loadTasks() {
|
||||
try {
|
||||
const response = await axios.get('/api/tasks');
|
||||
this.tasks = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshDevices() {
|
||||
this.refreshing = true;
|
||||
try {
|
||||
await axios.get('/api/devices/refresh');
|
||||
await this.loadDevices();
|
||||
this.showToast('Devices refreshed', 'success');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to refresh devices', 'error');
|
||||
} finally {
|
||||
this.refreshing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async executeTask(device) {
|
||||
if (!device.taskInput || !device.taskInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = device.taskInput.trim();
|
||||
device.taskInput = '';
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/tasks/execute', {
|
||||
device_id: device.device_id,
|
||||
task: task,
|
||||
max_steps: 100,
|
||||
lang: 'cn',
|
||||
// Backend will use config from environment
|
||||
});
|
||||
|
||||
this.showToast(`Task started: ${task}`, 'success');
|
||||
await this.loadTasks();
|
||||
} catch (error) {
|
||||
this.showToast('Failed to start task', 'error');
|
||||
console.error('Error executing task:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async stopTask(task) {
|
||||
try {
|
||||
await axios.post(`/api/tasks/${task.task_id}/stop`);
|
||||
this.showToast('Stopping task...', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to stop task', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async reExecuteTask(task) {
|
||||
const device = this.devices.find(d => d.device_id === task.device_id);
|
||||
if (!device) {
|
||||
this.showToast('Device not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.is_connected) {
|
||||
this.showToast('Device is not connected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (device.status === 'busy') {
|
||||
this.showToast('Device is busy', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/tasks/execute', {
|
||||
device_id: task.device_id,
|
||||
task: task.task,
|
||||
max_steps: task.max_steps || 100,
|
||||
lang: 'cn',
|
||||
});
|
||||
|
||||
this.showToast(`Task re-executed: ${task.task}`, 'success');
|
||||
await this.loadTasks();
|
||||
} catch (error) {
|
||||
this.showToast('Failed to re-execute task', 'error');
|
||||
console.error('Error re-executing task:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async captureScreenshot(deviceId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/devices/${deviceId}/screenshot`);
|
||||
const device = this.devices.find(d => d.device_id === deviceId);
|
||||
if (device) {
|
||||
device.screenshot = response.data.screenshot;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error capturing screenshot:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/* WebSocket Methods */
|
||||
connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}/ws`;
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.wsConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
console.log('WebSocket connected');
|
||||
|
||||
// Subscribe to all devices updates
|
||||
this.sendWebSocketMessage({
|
||||
type: 'subscribe',
|
||||
device_id: '*', // Subscribe to all devices
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this.handleWSMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.wsConnected = false;
|
||||
console.log('WebSocket disconnected');
|
||||
|
||||
// Attempt to reconnect
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
console.log(`Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
setTimeout(() => this.connectWebSocket(), this.reconnectDelay);
|
||||
} else {
|
||||
this.showToast('WebSocket connection lost', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
},
|
||||
|
||||
sendWebSocketMessage(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
},
|
||||
|
||||
handleWSMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'device_update':
|
||||
this.updateDevice(msg.data);
|
||||
break;
|
||||
|
||||
case 'task_started':
|
||||
this.handleTaskStarted(msg.data);
|
||||
break;
|
||||
|
||||
case 'task_step':
|
||||
this.handleTaskStep(msg.data);
|
||||
break;
|
||||
|
||||
case 'task_completed':
|
||||
this.handleTaskCompleted(msg.data);
|
||||
break;
|
||||
|
||||
case 'task_failed':
|
||||
this.handleTaskFailed(msg.data);
|
||||
break;
|
||||
|
||||
case 'task_stopped':
|
||||
this.handleTaskStopped(msg.data);
|
||||
break;
|
||||
|
||||
case 'screenshot':
|
||||
this.handleScreenshot(msg.data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.showToast(msg.data.error || 'An error occurred', 'error');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Ping response, ignore
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', msg.type);
|
||||
}
|
||||
},
|
||||
|
||||
/* Message Handlers */
|
||||
updateDevice(data) {
|
||||
const device = this.devices.find(d => d.device_id === data.device_id);
|
||||
if (device) {
|
||||
Object.assign(device, {
|
||||
status: data.status,
|
||||
is_connected: data.is_connected,
|
||||
model: data.model,
|
||||
current_app: data.current_app,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleTaskStarted(data) {
|
||||
this.tasks.unshift({
|
||||
task_id: data.task_id,
|
||||
device_id: data.device_id,
|
||||
task: data.task,
|
||||
status: 'running',
|
||||
current_step: 0,
|
||||
max_steps: data.max_steps || 100,
|
||||
current_action: null,
|
||||
thinking: null,
|
||||
started_at: new Date(),
|
||||
});
|
||||
|
||||
// Update device status
|
||||
const device = this.devices.find(d => d.device_id === data.device_id);
|
||||
if (device) {
|
||||
device.status = 'busy';
|
||||
}
|
||||
},
|
||||
|
||||
handleTaskStep(data) {
|
||||
const task = this.tasks.find(t => t.task_id === data.task_id);
|
||||
if (task) {
|
||||
task.current_step = data.step;
|
||||
task.current_action = data.action;
|
||||
task.thinking = data.thinking;
|
||||
// Store the completion message
|
||||
if (data.message && data.finished) {
|
||||
task.completion_message = data.message;
|
||||
}
|
||||
|
||||
if (data.finished) {
|
||||
task.status = data.success ? 'completed' : 'failed';
|
||||
this.releaseDevice(data.device_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleTaskCompleted(data) {
|
||||
const task = this.tasks.find(t => t.task_id === data.task_id);
|
||||
if (task) {
|
||||
task.status = 'completed';
|
||||
task.finished_at = new Date();
|
||||
// Store completion message
|
||||
if (data.message) {
|
||||
task.completion_message = data.message;
|
||||
}
|
||||
this.releaseDevice(data.device_id);
|
||||
this.showToast(`Task completed: ${task.task}`, 'success');
|
||||
}
|
||||
},
|
||||
|
||||
handleTaskFailed(data) {
|
||||
const task = this.tasks.find(t => t.task_id === data.task_id);
|
||||
if (task) {
|
||||
task.status = 'failed';
|
||||
task.error = data.error;
|
||||
task.finished_at = new Date();
|
||||
this.releaseDevice(data.device_id);
|
||||
this.showToast(`Task failed: ${data.error}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
handleTaskStopped(data) {
|
||||
const task = this.tasks.find(t => t.task_id === data.task_id);
|
||||
if (task) {
|
||||
task.status = 'stopped';
|
||||
task.finished_at = new Date();
|
||||
this.releaseDevice(data.device_id);
|
||||
this.showToast('Task stopped', 'info');
|
||||
}
|
||||
},
|
||||
|
||||
handleScreenshot(data) {
|
||||
const device = this.devices.find(d => d.device_id === data.device_id);
|
||||
if (device) {
|
||||
device.screenshot = data.screenshot;
|
||||
}
|
||||
},
|
||||
|
||||
releaseDevice(deviceId) {
|
||||
const device = this.devices.find(d => d.device_id === deviceId);
|
||||
if (device && device.status === 'busy') {
|
||||
device.status = 'online';
|
||||
}
|
||||
},
|
||||
|
||||
/* Utility Methods */
|
||||
formatAppName(packageName) {
|
||||
if (!packageName) return '';
|
||||
// Format package name for display
|
||||
const parts = packageName.split('.');
|
||||
return parts[parts.length - 1] || packageName;
|
||||
},
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const id = this.toastIdCounter++;
|
||||
this.toasts.push({ id, message, type });
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
/* Heartbeat */
|
||||
startHeartbeat() {
|
||||
setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.sendWebSocketMessage({ type: 'ping' });
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Load initial data
|
||||
this.loadDevices();
|
||||
this.loadTasks();
|
||||
|
||||
// Connect WebSocket
|
||||
this.connectWebSocket();
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Refresh devices periodically
|
||||
setInterval(() => {
|
||||
if (!this.refreshing) {
|
||||
this.loadDevices();
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
|
||||
// Refresh tasks periodically
|
||||
setInterval(() => {
|
||||
this.loadTasks();
|
||||
}, 5000); // Every 5 seconds
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
// Close WebSocket on unmount
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
},
|
||||
}).mount('#app');
|
||||
Reference in New Issue
Block a user