'use strict';
const API = ''; // same origin
// ── State ────────────────────────────────────────────────────────────────────
let profiles = [];
let sessions = []; // [{id, profile_id, last_active, preview}]
let currentSession = null; // session id
let ws = null;
let streaming = false;
let currentBubble = null; // DOM element being streamed into
// ── DOM refs ─────────────────────────────────────────────────────────────────
const profileSelect = document.getElementById('profile-select');
const btnNew = document.getElementById('btn-new');
const sessionList = document.getElementById('session-list');
const chatHeader = document.getElementById('chat-header');
const messagesEl = document.getElementById('messages');
const textarea = document.getElementById('input');
const btnSend = document.getElementById('btn-send');
// ── Init ─────────────────────────────────────────────────────────────────────
async function init() {
await loadProfiles();
await loadSessions();
textarea.addEventListener('keydown', onKey);
btnSend.addEventListener('click', sendMessage);
btnNew.addEventListener('click', createSession);
textarea.addEventListener('input', autoResize);
}
// ── Profiles ─────────────────────────────────────────────────────────────────
async function loadProfiles() {
try {
const res = await fetch(`${API}/agents/profiles`);
profiles = await res.json();
profileSelect.innerHTML = profiles.map(p =>
`<option value="${p.id}">${p.name}</option>`
).join('');
} catch (e) {
console.error('Failed to load profiles', e);
}
}
// ── Sessions ─────────────────────────────────────────────────────────────────
async function loadSessions() {
try {
const res = await fetch(`${API}/sessions`);
const data = await res.json();
sessions = data.map(s => ({ ...s, preview: '' }));
renderSessionList();
} catch (e) {
console.error('Failed to load sessions', e);
}
}
async function createSession() {
const profileId = profileSelect.value;
if (!profileId) return;
btnNew.disabled = true;
try {
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId }),
});
const session = await res.json();
sessions.unshift({ ...session, preview: 'New conversation' });
renderSessionList();
await switchSession(session.session_id);
} catch (e) {
console.error('Failed to create session', e);
} finally {
btnNew.disabled = false;
}
}
async function switchSession(sessionId) {
if (currentSession === sessionId) return;
disconnectWS();
currentSession = sessionId;
renderSessionList();
await loadHistory(sessionId);
connectWS(sessionId);
setInputEnabled(true);
}
async function loadHistory(sessionId) {
messagesEl.innerHTML = '';
try {
const res = await fetch(`${API}/sessions/${sessionId}`);
const data = await res.json();
updateChatHeader(data.profile_id);
const profile = profiles.find(p => p.id === data.profile_id);
for (const msg of data.messages) {
if (msg.role === 'system') continue;
if (msg.role === 'user') {
appendMessage('user', msg.content);
} else if (msg.role === 'assistant' && msg.content) {
appendMessage('assistant', msg.content);
}
}
scrollToBottom();
} catch (e) {
console.error('Failed to load history', e);
}
}
function renderSessionList() {
if (sessions.length === 0) {
sessionList.innerHTML = '<div class="empty-sessions">No conversations yet</div>';
return;
}
sessionList.innerHTML = sessions.map(s => {
const profile = profiles.find(p => p.id === s.profile_id);
const name = profile ? profile.name : s.profile_id;
const time = formatTime(s.last_active);
const preview = s.preview || 'No messages yet';
const active = s.session_id === currentSession ? ' active' : '';
return `
<div class="session-item${active}" data-id="${s.session_id}">
<div class="s-body">
<div class="s-profile">${escHtml(name)}</div>
<div class="s-preview">${escHtml(preview)}</div>
<div class="s-time">${time}</div>
</div>
<button class="btn-delete" data-id="${s.session_id}" title="Delete">×</button>
</div>`;
}).join('');
sessionList.querySelectorAll('.session-item').forEach(el => {
el.addEventListener('click', () => switchSession(el.dataset.id));
});
sessionList.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
deleteSession(btn.dataset.id);
});
});
}
async function deleteSession(sessionId) {
try {
await fetch(`${API}/sessions/${sessionId}`, { method: 'DELETE' });
} catch (e) {
console.error('Failed to delete session', e);
}
sessions = sessions.filter(s => s.session_id !== sessionId);
if (currentSession === sessionId) {
disconnectWS();
currentSession = null;
messagesEl.innerHTML = '<div class="empty-chat"><div class="icon">💬</div><p>Start a new conversation</p></div>';
chatHeader.textContent = 'Select a profile and start a new chat';
setInputEnabled(false);
}
renderSessionList();
}
function updateSessionPreview(sessionId, text) {
const s = sessions.find(s => s.session_id === sessionId);
if (s) s.preview = text.slice(0, 60);
renderSessionList();
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWS(sessionId) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/sessions/${sessionId}`;
ws = new WebSocket(url);
ws.onopen = () => console.log('WS connected', sessionId);
ws.onclose = (e) => {
console.log('WS closed', e.code);
if (streaming) finishStream();
};
ws.onerror = (e) => console.error('WS error', e);
ws.onmessage = (e) => handleWsEvent(JSON.parse(e.data));
}
function disconnectWS() {
if (ws) { ws.close(); ws = null; }
}
function handleWsEvent(event) {
switch (event.type) {
case 'stream_start':
streaming = true;
removeTypingIndicator();
currentBubble = appendMessage('assistant', '');
currentBubble.classList.add('cursor');
break;
case 'stream_delta':
if (currentBubble) {
currentBubble.textContent += event.delta;
scrollToBottom();
}
break;
case 'tool_call':
appendToolCall(event);
scrollToBottom();
break;
case 'stream_end':
finishStream(event.content);
setInputEnabled(true);
break;
case 'error':
finishStream();
appendError(event.message);
setInputEnabled(true);
break;
}
}
function finishStream(finalContent) {
streaming = false;
removeTypingIndicator();
if (currentBubble) {
currentBubble.classList.remove('cursor');
if (finalContent !== undefined) {
currentBubble.textContent = finalContent;
updateSessionPreview(currentSession, finalContent);
}
currentBubble = null;
}
scrollToBottom();
}
// ── Sending ───────────────────────────────────────────────────────────────────
async function sendMessage() {
const text = textarea.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN || streaming) return;
textarea.value = '';
autoResize();
setInputEnabled(false);
appendMessage('user', text);
appendTypingIndicator();
scrollToBottom();
ws.send(JSON.stringify({ type: 'message', content: text }));
}
function onKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
function appendMessage(role, content) {
const msg = document.createElement('div');
msg.className = `msg ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = content;
const time = document.createElement('div');
time.className = 'msg-time';
time.textContent = formatTime(new Date().toISOString());
msg.appendChild(bubble);
msg.appendChild(time);
messagesEl.appendChild(msg);
return bubble;
}
function appendToolCall(event) {
const icons = { web_search: '🔍', filesystem: '📁', http_request: '🌐', code_exec: '⚙️', terminal: '💻' };
const icon = icons[event.tool] || '🔧';
const card = document.createElement('div');
card.className = `tool-card${event.success ? '' : ' error'}`;
card.innerHTML = `
<div class="tool-header">
<span class="tool-icon">${icon}</span>
<span>${escHtml(event.tool)}</span>
</div>
<div class="tool-result">${escHtml(event.result)}</div>`;
messagesEl.appendChild(card);
}
function appendTypingIndicator() {
removeTypingIndicator();
const el = document.createElement('div');
el.className = 'typing';
el.id = 'typing';
el.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(el);
}
function removeTypingIndicator() {
document.getElementById('typing')?.remove();
}
function appendError(message) {
const el = document.createElement('div');
el.className = 'msg-error';
el.textContent = `Error: ${message}`;
messagesEl.appendChild(el);
}
function updateChatHeader(profileId) {
const profile = profiles.find(p => p.id === profileId);
const name = profile ? profile.name : profileId;
chatHeader.innerHTML = `<span class="profile-badge">${escHtml(profileId)}</span> ${escHtml(name)}`;
}
function setInputEnabled(enabled) {
textarea.disabled = !enabled;
btnSend.disabled = !enabled;
if (enabled) textarea.focus();
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const now = new Date();
const diff = now - d;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString();
}
// ── Start ─────────────────────────────────────────────────────────────────────
init();