import { marked } from 'marked';
import DOMPurify from 'dompurify';
import './style.css';
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');
const sidebarEl = document.querySelector('.sidebar');
const menuToggle = document.getElementById('menu-toggle');
const sidebarOverlay = document.getElementById('sidebar-overlay');
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 = `
${icons[type] || 'ℹ'}
${message}
`;
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 `
${task.goal}
${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`;
})
.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);
// Fetch and update subscription status
fetch(`${API_URL}/tasks/${id}/subscription`)
.then(res => res.json())
.then(data => {
const btn = document.getElementById('notify-task-btn');
if (data.isSubscribed) {
btn.classList.add('notified');
} else {
btn.classList.remove('notified');
}
})
.catch(err => console.error('Failed to fetch subscription status', err));
// Close sidebar on mobile after selection
if (window.innerWidth <= 768) {
closeMobileMenu();
}
}
function renderRunHistory(task) {
runListEl.innerHTML = task.runs
.map(
(run, index) => `
Run #${task.runs.length - index}
${new Date(run.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`
)
.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) => `
| ${escapeHtml(run.goal)} |
${run.status} |
${new Date(run.created_at).toLocaleString()} |
`
)
.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 ? `🕒 Scheduled: ${task.cron}
` : '';
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 = 'No logs available. Click "Run Task" to start the agent.
';
answerContainerEl.classList.remove('hidden');
answerOutputEl.innerHTML = 'Waiting for the first execution...
';
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 = 'Agent is working on the final answer...
';
}
// 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) => `${escapeHtml(line)}
`)
.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 = `
Start a conversation with the assistant.
`;
return;
}
chatMessagesEl.innerHTML = state.chatMessages
.map(msg => `
${DOMPurify.sanitize(marked.parse(msg.content || ''))}
`)
.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 ? '⌨ Inspect Logs' : '✕ 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();
});
});
function toggleMobileMenu() {
sidebarEl.classList.toggle('open');
menuToggle.classList.toggle('active');
sidebarOverlay.classList.toggle('hidden');
document.body.style.overflow = sidebarEl.classList.contains('open') ? 'hidden' : '';
}
function closeMobileMenu() {
sidebarEl.classList.remove('open');
menuToggle.classList.remove('active');
sidebarOverlay.classList.add('hidden');
document.body.style.overflow = '';
}
menuToggle.addEventListener('click', toggleMobileMenu);
sidebarOverlay.addEventListener('click', closeMobileMenu);
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();
}
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('SW registered', reg);
setupPush(reg);
})
.catch(err => console.error('SW registration failed', err));
});
}
async function setupPush(registration) {
try {
const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`);
const { publicKey } = await vapidResponse.json();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
await fetch(`${API_URL}/notifications/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
p256dh: b64(subscription.getKey('p256dh')),
auth: b64(subscription.getKey('auth'))
})
});
console.log('Push registered');
} catch (err) {
console.warn('Push registration failed:', err);
}
}
function b64(buffer) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function toggleTaskSubscription(taskId) {
const btn = document.getElementById('notify-task-btn');
const isNotified = btn.classList.contains('notified');
const method = isNotified ? 'DELETE' : 'POST';
try {
await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method });
btn.classList.toggle('notified');
showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled');
} catch (err) {
showToast('Failed to update notifications');
}
}
document.getElementById('notify-task-btn').addEventListener('click', () => {
if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId);
});