Newer
Older
navi-1 / client / app.js
@Eugene Sukhodolskiy Eugene Sukhodolskiy on 8 Apr 10 KB Add web chat client
'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-profile">${escHtml(name)}</div>
        <div class="s-preview">${escHtml(preview)}</div>
        <div class="s-time">${time}</div>
      </div>`;
  }).join('');

  sessionList.querySelectorAll('.session-item').forEach(el => {
    el.addEventListener('click', () => switchSession(el.dataset.id));
  });
}

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

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();