bot/frontend/src/main.js
2026-02-11 17:09:49 +01:00

774 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { marked } from 'marked';
import DOMPurify from 'dompurify';
const API_URL = '/api';
// These should ideally be environment-specific
const AUTH_CONFIG = {
issuer: 'https://idm.flegr.me/application/o/bot/',
clientId: 'CicDk8mpSBY1SW4ofCamO3B583ttmvKTSPDQwvpb',
redirectUri: window.location.origin + '/callback',
authorizeEndpoint: 'https://idm.flegr.me/application/o/authorize/',
};
const state = {
tasks: [],
selectedTaskId: null,
selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false,
isAuthenticated: false,
chatMessages: [],
activeDashboardTab: 'chat' // 'chat' or 'activity'
};
// DOM elements
const loginOverlay = document.getElementById('login-overlay');
const callbackOverlay = document.getElementById('callback-overlay');
const loginBtn = document.getElementById('login-btn');
const appEl = document.getElementById('app');
const logoutBtn = document.getElementById('logout-btn');
const taskListEl = document.getElementById('task-list');
const newTaskBtn = document.getElementById('new-task-btn');
const modalContainer = document.getElementById('modal-container');
const newTaskForm = document.getElementById('new-task-form');
const cancelTaskBtn = document.getElementById('cancel-task');
const emptyStateEl = document.getElementById('empty-state');
const taskViewEl = document.getElementById('task-view');
const logsOutputEl = document.getElementById('logs-output');
const viewGoalEl = document.getElementById('view-goal');
const viewStatusEl = document.getElementById('view-status');
const viewDateEl = document.getElementById('view-date');
const answerContainerEl = document.getElementById('answer-container');
const answerOutputEl = document.getElementById('answer-output');
const rerunBtn = document.getElementById('rerun-btn');
const runListEl = document.getElementById('run-list');
const toggleLogsBtn = document.getElementById('toggle-logs-btn');
const logsContainerEl = document.getElementById('logs-container');
const dashboardViewEl = document.getElementById('dashboard-view');
const recentRunsListEl = document.getElementById('recent-runs-list');
const logoLink = document.getElementById('logo-link');
const editTaskBtn = document.getElementById('edit-task-btn');
const modalTitle = document.getElementById('modal-title');
const submitTaskBtn = document.getElementById('submit-task-btn');
const goalTextarea = document.getElementById('goal-textarea');
const cronInput = document.getElementById('cron-input');
const schedulePresets = document.getElementById('schedule-presets');
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
const customCronContainer = document.getElementById('custom-cron-container');
const presetBtns = document.querySelectorAll('.btn-preset');
const chatMessagesEl = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const clearChatBtn = document.getElementById('clear-chat-btn');
const chatSendBtn = document.getElementById('chat-send-btn');
function updateState(newState) {
Object.assign(state, newState);
renderApp();
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
// Auto remove
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s forwards';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function renderApp() {
renderTaskList();
if (state.currentView === 'dashboard') {
fetchRecentRuns();
renderChat();
} else if (state.selectedTaskId) {
const task = state.tasks.find(t => t.id === state.selectedTaskId);
if (task) {
renderRunHistory(task);
showTaskView(task);
}
}
}
// Wrapper for fetch to include Authorization header
async function fetchWithAuth(url, options = {}) {
let response = await fetch(url, { ...options, credentials: 'include' });
if (response.status === 401) {
// Try to refresh token
try {
const success = await attemptTokenRefresh();
if (success) {
// Retry original request
response = await fetch(url, { ...options, credentials: 'include' });
}
} catch (error) {
console.error('Token refresh failed:', error);
}
}
if (response.status === 401) {
logout();
throw new Error('Session expired');
}
return response;
}
async function attemptTokenRefresh() {
try {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ refresh_token: '' })
});
if (response.ok) {
return true;
}
} catch (error) {
console.error('Error during token refresh:', error);
}
return false;
}
// I'll replace the fetchTasks function and add updateState
async function fetchTasks() {
try {
const response = await fetchWithAuth(`${API_URL}/tasks`);
const newTasks = await response.json();
// Check if we should follow the latest run
let newSelectedRunId = state.selectedRunId;
if (state.selectedTaskId) {
const currentTask = newTasks.find(t => t.id === state.selectedTaskId);
if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
// If we don't have a selected run or the runs changed, we might want to update
if (!state.selectedRunId || (state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.length !== currentTask.runs.length)) {
// Only auto-switch if we are "following" the latest
const wasFollowingLatest = state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.[0]?.id === state.selectedRunId;
if (wasFollowingLatest || !state.selectedRunId) {
newSelectedRunId = currentTask.runs[0].id;
}
}
}
}
updateState({
tasks: newTasks,
selectedRunId: newSelectedRunId
});
} catch (error) {
console.error('Error fetching tasks:', error);
}
}
async function fetchRecentRuns() {
try {
const response = await fetchWithAuth(`${API_URL}/runs/recent`);
const recentRuns = await response.json();
renderDashboard(recentRuns);
} catch (error) {
console.error('Error fetching recent runs:', error);
}
}
function renderTaskList() {
const sortedTasks = [...state.tasks].sort((a, b) => {
return new Date(b.created_at) - new Date(a.created_at);
});
taskListEl.innerHTML = sortedTasks
.map((task) => {
const latestRun = task.runs && task.runs.length > 0
? task.runs[0]
: null;
const status = latestRun ? latestRun.status : 'pending';
const date = latestRun ? new Date(latestRun.created_at) : new Date(task.created_at);
return `
<li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}">
<div class="task-item-title">${task.goal}</div>
<div class="task-item-meta">
<span class="status-dot ${status}"></span>
<span>${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</li>
`;
})
.join('');
// Add event listeners
document.querySelectorAll('.task-item').forEach((item) => {
item.addEventListener('click', () => {
selectTask(item.dataset.id);
});
});
}
function selectTask(id, runId = null) {
state.selectedTaskId = id;
state.currentView = 'task';
const task = state.tasks.find((t) => t.id === id);
if (!task) return;
if (runId) {
state.selectedRunId = runId;
} else if (task.runs && task.runs.length > 0) {
// Default to latest run if not specified (index 0 is newest)
state.selectedRunId = task.runs[0].id;
} else {
state.selectedRunId = null;
}
renderTaskList();
renderRunHistory(task);
showTaskView(task);
}
function renderRunHistory(task) {
runListEl.innerHTML = task.runs
.map(
(run, index) => `
<li class="run-item ${state.selectedRunId === run.id ? 'active' : ''}" data-id="${run.id}">
<span>Run #${task.runs.length - index}</span>
<span class="run-item-date">${new Date(run.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</li>
`
)
.join('');
document.querySelectorAll('.run-item').forEach((item) => {
item.addEventListener('click', () => {
selectTask(task.id, item.dataset.id);
});
});
}
function showDashboard() {
state.currentView = 'dashboard';
state.selectedTaskId = null;
state.selectedRunId = null;
emptyStateEl.classList.add('hidden');
taskViewEl.classList.add('hidden');
dashboardViewEl.classList.remove('hidden');
renderTaskList();
fetchRecentRuns();
renderDashboardTabs();
}
function renderDashboardTabs() {
const tabs = document.querySelectorAll('.tab-btn');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
if (tab.dataset.tab === state.activeDashboardTab) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
panels.forEach(panel => {
if (panel.id === `${state.activeDashboardTab}-tab-panel`) {
panel.classList.add('active');
} else {
panel.classList.remove('active');
}
});
}
function renderDashboard(recentRuns) {
recentRunsListEl.innerHTML = recentRuns
.map(
(run) => `
<tr data-task-id="${run.task_id}" data-run-id="${run.id}">
<td>${escapeHtml(run.goal)}</td>
<td><span class="status-badge ${run.status}">${run.status}</span></td>
<td>${new Date(run.created_at).toLocaleString()}</td>
</tr>
`
)
.join('');
document.querySelectorAll('#recent-runs-list tr').forEach((row) => {
row.addEventListener('click', () => {
selectTask(row.dataset.taskId, row.dataset.runId);
});
});
}
function showTaskView(task) {
state.currentView = 'task';
emptyStateEl.classList.add('hidden');
dashboardViewEl.classList.add('hidden');
taskViewEl.classList.remove('hidden');
const run = task.runs.find(r => r.id === state.selectedRunId) || task.runs[0];
viewGoalEl.textContent = task.goal;
// Show schedule info if exists
const scheduleInfo = task.cron ? `<div class="schedule-badge">🕒 Scheduled: ${task.cron}</div>` : '';
const headerMain = document.querySelector('.header-main');
const existingBadge = headerMain.querySelector('.schedule-badge');
if (existingBadge) existingBadge.remove();
if (scheduleInfo) {
headerMain.insertAdjacentHTML('beforeend', scheduleInfo);
}
if (!run) {
viewStatusEl.textContent = 'No runs';
viewStatusEl.className = 'status-badge pending';
viewDateEl.textContent = '-';
logsOutputEl.innerHTML = '<div class="waiting-placeholder">No logs available. Click "Run Task" to start the agent.</div>';
answerContainerEl.classList.remove('hidden');
answerOutputEl.innerHTML = '<div class="waiting-placeholder">Waiting for the first execution...</div>';
return;
}
viewStatusEl.textContent = run.status;
viewStatusEl.className = `status-badge ${run.status}`;
viewDateEl.textContent = new Date(run.created_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
answerContainerEl.classList.remove('hidden');
if (run.answer) {
const rawHtml = marked.parse(run.answer);
answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml);
} else {
answerOutputEl.innerHTML = '<div class="waiting-placeholder">Agent is working on the final answer...</div>';
}
// Check if we should auto-scroll
const isAtBottom = logsOutputEl.scrollHeight - logsOutputEl.scrollTop <= logsOutputEl.clientHeight + 10;
const isFirstLoad = logsOutputEl.innerHTML === '';
// Simple log format
logsOutputEl.innerHTML = run.logs
.split('\n')
.map((line) => `<div class="log-entry">${escapeHtml(line)}</div>`)
.join('');
if (isAtBottom || isFirstLoad) {
logsOutputEl.scrollTop = logsOutputEl.scrollHeight;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderChat() {
if (!chatMessagesEl) return;
if (state.chatMessages.length === 0) {
chatMessagesEl.innerHTML = `
<div class="chat-empty-state">
<p>Start a conversation with the assistant.</p>
</div>
`;
return;
}
chatMessagesEl.innerHTML = state.chatMessages
.map(msg => `
<div class="chat-message ${msg.role}">
${escapeHtml(msg.content)}
</div>
`)
.join('');
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
}
async function sendChatMessage(text) {
const userMessage = { role: 'user', content: text };
state.chatMessages.push(userMessage);
renderChat();
chatInput.value = '';
chatInput.disabled = true;
chatSendBtn.disabled = true;
try {
const response = await fetchWithAuth(`${API_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: state.chatMessages })
});
if (!response.ok) throw new Error('Chat API failed');
const result = await response.json();
state.chatMessages.push(result.message);
renderChat();
} catch (error) {
console.error('Chat error:', error);
showToast('Failed to get chat response.', 'error');
state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
renderChat();
} finally {
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
}
// Event Listeners
rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return;
try {
const response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}/runs`, {
method: 'POST',
});
const updatedTask = await response.json();
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
if (index !== -1) {
state.tasks[index] = updatedTask;
}
selectTask(updatedTask.id);
showToast('Task rerun successfully!', 'success');
} catch (error) {
console.error('Failed to rerun task:', error);
showToast('Failed to rerun task.', 'error');
}
});
newTaskBtn.addEventListener('click', () => {
state.isEditing = false;
modalTitle.textContent = 'New Agent Task';
submitTaskBtn.textContent = 'Save Task';
goalTextarea.value = '';
cronInput.value = '';
updateScheduleUI('');
modalContainer.classList.remove('hidden');
});
toggleLogsBtn.addEventListener('click', () => {
logsContainerEl.classList.toggle('hidden');
const isHidden = logsContainerEl.classList.contains('hidden');
toggleLogsBtn.classList.toggle('active', !isHidden);
toggleLogsBtn.innerHTML = isHidden ? '<span>⌨</span> Inspect Logs' : '<span>✕</span> Hide Logs';
});
editTaskBtn.addEventListener('click', () => {
const task = state.tasks.find((t) => t.id === state.selectedTaskId);
if (!task) return;
state.isEditing = true;
modalTitle.textContent = 'Edit Agent Task';
submitTaskBtn.textContent = 'Save Task';
goalTextarea.value = task.goal;
cronInput.value = task.cron || '';
updateScheduleUI(task.cron || '');
modalContainer.classList.remove('hidden');
});
cancelTaskBtn.addEventListener('click', () => {
modalContainer.classList.add('hidden');
state.isEditing = false;
});
logoLink.addEventListener('click', (e) => {
e.preventDefault();
showDashboard();
});
function updateScheduleUI(cron) {
let matched = false;
presetBtns.forEach(btn => {
if (btn.dataset.cron === (cron || '')) {
btn.classList.add('active');
matched = true;
} else {
btn.classList.remove('active');
}
});
if (matched) {
customCronContainer.classList.add('hidden');
} else if (cron) {
customCronContainer.classList.remove('hidden');
} else {
customCronContainer.classList.add('hidden');
}
}
presetBtns.forEach(btn => {
btn.addEventListener('click', () => {
const cron = btn.dataset.cron;
cronInput.value = cron;
updateScheduleUI(cron);
});
});
toggleCustomCronBtn.addEventListener('click', () => {
customCronContainer.classList.toggle('hidden');
});
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (text) {
sendChatMessage(text);
}
});
clearChatBtn.addEventListener('click', () => {
state.chatMessages = [];
renderChat();
});
cronInput.addEventListener('input', () => {
// If user types manually, update presets active state
updateScheduleUI(cronInput.value);
});
newTaskForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(newTaskForm);
const goal = formData.get('goal');
const cron = formData.get('cron') || null;
modalContainer.classList.add('hidden');
newTaskForm.reset();
try {
let response;
if (state.isEditing) {
response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, cron }),
});
} else {
response = await fetchWithAuth(`${API_URL}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal, cron }),
});
}
const updatedTask = await response.json();
if (state.isEditing) {
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
if (index !== -1) {
state.tasks[index] = updatedTask;
}
} else {
state.tasks.unshift(updatedTask);
}
state.isEditing = false;
selectTask(updatedTask.id);
renderTaskList();
showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
} catch (error) {
console.error('Save task failed:', error);
showToast('Failed to execute task. Check console.', 'error');
}
});
async function checkSession() {
try {
const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' });
if (response.ok) {
state.isAuthenticated = true;
return true;
}
} catch (error) {
console.error('Session check failed:', error);
}
state.isAuthenticated = false;
return false;
}
let socket = null;
let reconnectDelay = 1000;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
console.log('Connecting to WebSocket:', wsUrl);
socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('WebSocket connected');
reconnectDelay = 1000;
// Initial fetch to sync state
fetchTasks();
};
socket.onmessage = (event) => {
try {
const { type, data } = JSON.parse(event.data);
console.log('WebSocket event:', type, data);
switch (type) {
case 'TaskCreated':
state.tasks.unshift(data);
renderApp();
showToast('New task created', 'success');
break;
case 'TaskUpdated':
case 'RunFinished':
const index = state.tasks.findIndex(t => t.id === data.id);
if (index !== -1) {
const wasSelected = state.selectedTaskId === data.id;
state.tasks[index] = data;
if (wasSelected) {
// Update selected run if we were following latest
const wasFollowingLatest = state.selectedRunId === state.tasks[index].runs?.[1]?.id || !state.selectedRunId;
if (wasFollowingLatest && data.runs && data.runs.length > 0) {
state.selectedRunId = data.runs[0].id;
}
}
} else {
state.tasks.unshift(data);
}
renderApp();
if (type === 'RunFinished') {
showToast(`Task run completed: ${data.goal}`, 'info');
}
break;
case 'RunStarted':
const taskIndex = state.tasks.findIndex(t => t.id === data.task_id);
if (taskIndex !== -1) {
// We don't have the full task update here, but we can update status
// For simplicity, we just trigger a fetch or wait for RunFinished
// But let's at least show it's running in the UI if selected
if (state.tasks[taskIndex].runs) {
// Prepend a dummy run or just fetch
fetchTasks();
}
}
showToast(`Task started: ${data.goal}`, 'info');
break;
}
} catch (e) {
console.error('Error handling WebSocket message:', e);
}
};
socket.onclose = () => {
console.log('WebSocket disconnected. Reconnecting...');
setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
connectWebSocket();
}, reconnectDelay);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
socket.close();
};
}
async function showLogin() {
loginOverlay.classList.remove('hidden');
appEl.classList.add('hidden');
}
async function logout() {
try {
await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
} catch (error) {
console.error('Logout failed:', error);
}
state.isAuthenticated = false;
showLogin();
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) return;
window.history.replaceState({}, document.title, "/");
callbackOverlay.classList.remove('hidden');
loginOverlay.classList.add('hidden');
try {
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, {
credentials: 'include'
});
const data = await response.json();
if (response.ok) {
state.isAuthenticated = true;
callbackOverlay.classList.add('hidden');
appEl.classList.remove('hidden');
initializeApp();
} else {
throw new Error('No access token in response');
}
} catch (error) {
console.error('Callback failed:', error);
showToast('Authentication failed.', 'error');
showLogin();
}
}
loginBtn.addEventListener('click', () => {
const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email offline_access`;
window.location.href = authUrl;
});
logoutBtn.addEventListener('click', () => {
logout();
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.activeDashboardTab = btn.dataset.tab;
renderDashboardTabs();
});
});
async function initializeApp() {
const hasSession = await checkSession();
if (hasSession) {
appEl.classList.remove('hidden');
loginOverlay.classList.add('hidden');
await fetchTasks();
connectWebSocket();
} else {
showLogin();
}
}
// Check for callback on load
if (window.location.pathname === '/callback' || window.location.search.includes('code=')) {
handleCallback();
} else {
initializeApp();
}