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:
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