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>
405 lines
13 KiB
JavaScript
405 lines
13 KiB
JavaScript
/**
|
|
* 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');
|