Newer
Older
navi-1 / client / js / app.js
import { api }                                              from './api.js';
import { WsClient }                                          from './ws.js';
import { appendMessage, appendStreamBubble, finalizeStreamBubble,
         appendToolCall, appendTypingIndicator, removeTypingIndicator,
         appendError, showEmptyState, scrollToBottom }       from './chat.js';
import { renderProfiles, renderSessions, updateChatHeader }  from './sidebar.js';

// ── DOM refs ─────────────────────────────────────────────────────────────────

const profileSelect = document.getElementById('profile-select');
const btnNew        = document.getElementById('btn-new');
const sessionListEl = document.getElementById('session-list');
const chatHeaderEl  = document.getElementById('chat-header');
const messagesEl    = document.getElementById('messages');
const textarea      = document.getElementById('input');
const btnSend       = document.getElementById('btn-send');

// ── State ─────────────────────────────────────────────────────────────────────

const STORAGE_KEY = 'navi_current_session';

let profiles      = [];
let sessions      = [];
let currentId     = localStorage.getItem(STORAGE_KEY) ?? null;
let streaming     = false;
let currentBubble = null;

const ws = new WsClient();

// ── Boot ──────────────────────────────────────────────────────────────────────

async function init() {
  textarea.addEventListener('keydown', onKey);
  textarea.addEventListener('input', autoResize);
  btnSend.addEventListener('click', sendMessage);
  btnNew.addEventListener('click', newChat);

  [profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]);

  // Attach preview text from message history
  sessions = await Promise.all(sessions.map(enrichSession));

  renderProfiles(profileSelect, profiles);
  rerenderSidebar();

  // Restore last active session
  if (currentId && sessions.some(s => s.session_id === currentId)) {
    await openSession(currentId, false);
  } else {
    currentId = null;
    showEmptyState(messagesEl);
    setInputEnabled(false);
  }
}

// ── Sessions ──────────────────────────────────────────────────────────────────

async function newChat() {
  const profileId = profileSelect.value;
  if (!profileId) return;
  btnNew.disabled = true;
  try {
    const session = await api.createSession(profileId);
    sessions.unshift({ ...session, preview: '', profile_name: profileName(profileId) });
    rerenderSidebar();
    await openSession(session.session_id, false);
  } finally {
    btnNew.disabled = false;
  }
}

async function openSession(sessionId, skipLoad = false) {
  ws.disconnect();
  currentId = sessionId;
  localStorage.setItem(STORAGE_KEY, sessionId);
  rerenderSidebar();

  const s = sessions.find(s => s.session_id === sessionId);
  const pId   = s?.profile_id   ?? '';
  const pName = s?.profile_name ?? profileName(pId);
  updateChatHeader(chatHeaderEl, pId, pName);

  if (!skipLoad) {
    await loadHistory(sessionId);
  }

  connectWs(sessionId);
  setInputEnabled(true);
}

async function loadHistory(sessionId) {
  messagesEl.innerHTML = '';
  try {
    const data = await api.getSession(sessionId);
    for (const msg of data.messages) {
      if (msg.role === 'system') continue;
      if (msg.role === 'user' || (msg.role === 'assistant' && msg.content)) {
        appendMessage(messagesEl, msg.role, msg.content);
      }
    }
    scrollToBottom(messagesEl);
  } catch (e) {
    console.error('loadHistory', e);
  }
}

async function deleteSession(sessionId) {
  await api.deleteSession(sessionId).catch(console.error);
  sessions = sessions.filter(s => s.session_id !== sessionId);
  if (currentId === sessionId) {
    ws.disconnect();
    currentId = null;
    localStorage.removeItem(STORAGE_KEY);
    showEmptyState(messagesEl);
    updateChatHeader(chatHeaderEl, null);
    setInputEnabled(false);
  }
  rerenderSidebar();
}

// ── WebSocket ─────────────────────────────────────────────────────────────────

function connectWs(sessionId) {
  ws.connect(sessionId, {
    onClose: () => { if (streaming) finishStream(); },
    onMessage: handleWsEvent,
  });
}

function handleWsEvent(event) {
  switch (event.type) {
    case 'stream_start':
      streaming = true;
      removeTypingIndicator(messagesEl);
      currentBubble = appendStreamBubble(messagesEl);
      break;

    case 'stream_delta':
      if (currentBubble) {
        currentBubble.textContent += event.delta;
        scrollToBottom(messagesEl);
      }
      break;

    case 'tool_call':
      appendToolCall(messagesEl, event);
      scrollToBottom(messagesEl);
      break;

    case 'stream_end':
      finishStream(event.content);
      setInputEnabled(true);
      break;

    case 'error':
      finishStream();
      appendError(messagesEl, event.message);
      setInputEnabled(true);
      break;
  }
}

function finishStream(finalContent) {
  streaming = false;
  removeTypingIndicator(messagesEl);
  if (currentBubble) {
    if (finalContent !== undefined) {
      finalizeStreamBubble(currentBubble, finalContent);
      updatePreview(currentId, finalContent);
    } else {
      currentBubble.classList.remove('cursor');
    }
    currentBubble = null;
  }
  scrollToBottom(messagesEl);
}

// ── Sending ───────────────────────────────────────────────────────────────────

async function sendMessage() {
  const text = textarea.value.trim();
  if (!text || !ws.ready || streaming) return;

  textarea.value = '';
  autoResize();
  setInputEnabled(false);

  appendMessage(messagesEl, 'user', text);
  appendTypingIndicator(messagesEl);
  scrollToBottom(messagesEl);

  ws.send(text);
}

function onKey(e) {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function rerenderSidebar() {
  renderSessions(sessionListEl, sessions, currentId, {
    onSelect: (id) => { if (id !== currentId) openSession(id); },
    onDelete: deleteSession,
  });
}

function updatePreview(sessionId, text) {
  const s = sessions.find(s => s.session_id === sessionId);
  if (s) s.preview = text.slice(0, 60);
  rerenderSidebar();
}

function profileName(profileId) {
  return profiles.find(p => p.id === profileId)?.name ?? profileId;
}

async function enrichSession(s) {
  return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' };
}

function setInputEnabled(on) {
  textarea.disabled = !on;
  btnSend.disabled  = !on;
  if (on) textarea.focus();
}

function autoResize() {
  textarea.style.height = 'auto';
  textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
}

// ── Start ─────────────────────────────────────────────────────────────────────

init();