Newer
Older
navi-1 / client / js / app.js
import { api }                                              from './api.js';
import { WsClient }                                          from './ws.js';
import { appendMessage, appendStreamBubble, finalizeStreamBubble,
         appendToolCall, appendThinkingCard, finalizeThinkingCard,
         appendTypingIndicator, removeTypingIndicator,
         appendError, showEmptyState, scrollToBottom,
         appendSummaryCard, appendCompressionNotice }         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 tokenCounterEl  = document.getElementById('token-counter');
const messagesEl      = document.getElementById('messages');
const textarea        = document.getElementById('input');
const btnSend         = document.getElementById('btn-send');
const btnAttach       = document.getElementById('btn-attach');
const fileInput       = document.getElementById('file-input');
const previewStrip    = document.getElementById('image-preview-strip');

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

let profiles         = [];
let sessions         = [];
let currentId        = null;
let streaming        = false;
let currentBubble    = null;
let currentThinking  = null;  // { card, pre } during thinking phase
let pendingImages    = [];   // array of full data URLs (data:image/...;base64,...)

const ws = new WsClient();

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

async function init() {
  textarea.addEventListener('keydown', onKey);
  textarea.addEventListener('input', autoResize);
  textarea.addEventListener('paste', onPaste);
  btnSend.addEventListener('click', sendMessage);
  btnNew.addEventListener('click', newChat);
  btnAttach.addEventListener('click', () => fileInput.click());
  fileInput.addEventListener('change', onFileChange);

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

  sessions = sessions.map(enrichSession);

  renderProfiles(profileSelect, profiles);
  rerenderSidebar();

  // Open session from URL hash, or fall back to most recently active
  const hashId = location.hash.slice(1);
  const targetId = hashId && sessions.some(s => s.session_id === hashId)
    ? hashId
    : sessions[0]?.session_id ?? null;

  if (targetId) {
    await openSession(targetId, false);
  } else {
    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;
  history.replaceState(null, '', '#' + sessionId);
  tokenCounterEl.hidden = true;
  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);

    // Build a lookup: tool_call_id → {name, arguments} from assistant tool_calls
    const toolCallMap = {};
    for (const msg of data.messages) {
      if (msg.role === 'assistant' && msg.tool_calls) {
        for (const tc of msg.tool_calls) {
          toolCallMap[tc.id] = { name: tc.name, args: tc.arguments ?? {} };
        }
      }
    }

    for (const msg of data.messages) {
      if (msg.role === 'system') continue;

      // is_summary messages exist only in session.messages (display history),
      // never in session.context. They are injected when the server loads a
      // session whose context was compressed — display them as collapsible cards.
      if (msg.is_summary) {
        appendSummaryCard(messagesEl, msg.content ?? '');
        continue;
      }

      if (msg.role === 'tool') {
        const tc = toolCallMap[msg.tool_call_id] ?? { name: msg.name ?? '?', args: {} };
        const success = !msg.content?.startsWith('Error:');
        appendToolCall(messagesEl, {
          tool: tc.name,
          args: tc.args,
          result: msg.content ?? '',
          success,
        });
        continue;
      }

      if (msg.role === 'user' || (msg.role === 'assistant' && msg.content)) {
        const imgs = msg.images?.map(b => b.startsWith('data:') ? b : `data:image/jpeg;base64,${b}`) ?? null;
        appendMessage(messagesEl, msg.role, msg.content, imgs, msg.created_at ?? null);
      }
    }

    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;
    history.replaceState(null, '', location.pathname);
    showEmptyState(messagesEl);
    updateChatHeader(chatHeaderEl, null);
    setInputEnabled(false);
  }
  rerenderSidebar();
}

async function pinSession(sessionId, pinned) {
  await api.pinSession(sessionId, pinned).catch(console.error);
  const s = sessions.find(s => s.session_id === sessionId);
  if (s) s.pinned = pinned;
  // Re-sort: pinned first
  sessions.sort((a, b) => (b.pinned - a.pinned) || (b.last_active > a.last_active ? 1 : -1));
  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;
      currentBubble = null;
      currentThinking = null;
      break;

    case 'thinking_delta':
      if (!currentThinking) {
        removeTypingIndicator(messagesEl);
        currentThinking = appendThinkingCard(messagesEl);
      }
      currentThinking.pre.textContent += event.delta;
      scrollToBottom(messagesEl);
      break;

    case 'thinking_end':
      if (currentThinking) {
        finalizeThinkingCard(currentThinking.card);
        currentThinking = null;
      }
      break;

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

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

    case 'stream_end':
      finishStream(event.content);
      updateTokenCounter(event.context_tokens, event.max_context_tokens);
      setInputEnabled(true);
      break;

    case 'context_compressed':
      appendCompressionNotice(messagesEl);
      scrollToBottom(messagesEl);
      break;

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

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

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

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

  const imagesToSend = [...pendingImages];  // full data URLs
  clearImages();
  textarea.value = '';
  autoResize();
  setInputEnabled(false);

  // Display with full data URLs
  appendMessage(messagesEl, 'user', text || null, imagesToSend.length ? imagesToSend : null);
  appendTypingIndicator(messagesEl);
  scrollToBottom(messagesEl);

  // Strip data URI prefix before sending to server (server expects raw base64)
  const b64List = imagesToSend.map(d => d.split(',', 2)[1]);
  ws.send(text || ' ', b64List.length ? b64List : null);
}

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,
    onPin:    pinSession,
  });
}

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;
}

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

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

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

// ── Image handling ────────────────────────────────────────────────────────────

function addImageFile(file) {
  if (!file.type.startsWith('image/')) return;
  const reader = new FileReader();
  reader.onload = (e) => {
    // Store the full data URL so we retain mime type for display
    pendingImages.push(e.target.result);
    renderPreviewStrip();
  };
  reader.readAsDataURL(file);
}

function onFileChange(e) {
  for (const file of e.target.files) addImageFile(file);
  fileInput.value = '';
}

function onPaste(e) {
  for (const item of e.clipboardData?.items ?? []) {
    if (item.kind === 'file' && item.type.startsWith('image/')) {
      e.preventDefault();
      addImageFile(item.getAsFile());
    }
  }
}

function clearImages() {
  pendingImages = [];
  previewStrip.innerHTML = '';
}

function renderPreviewStrip() {
  previewStrip.innerHTML = '';
  pendingImages.forEach((dataUrl) => {
    const wrap = document.createElement('div');
    wrap.className = 'img-thumb-wrap';

    const img = document.createElement('img');
    img.src = dataUrl;
    img.className = 'img-thumb';

    const btn = document.createElement('button');
    btn.className = 'img-thumb-remove';
    btn.textContent = '×';
    btn.addEventListener('click', () => {
      pendingImages.splice(pendingImages.indexOf(dataUrl), 1);
      renderPreviewStrip();
    });

    wrap.append(img, btn);
    previewStrip.appendChild(wrap);
  });
}

function updateTokenCounter(used, max) {
  if (!used || !max) { tokenCounterEl.hidden = true; return; }
  const pct = Math.round((used / max) * 100);
  tokenCounterEl.textContent = `${used.toLocaleString()}/${max.toLocaleString()} (${pct}%) tokens`;
  tokenCounterEl.classList.toggle('warn',   pct >= 50 && pct < 80);
  tokenCounterEl.classList.toggle('danger', pct >= 80);
  tokenCounterEl.hidden = false;
}

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

init();