Newer
Older
navi-1 / client / js / chat.js
/** Chat area DOM helpers. */

import { marked }   from 'https://esm.sh/marked@12';
import hljs         from 'https://esm.sh/highlight.js@11';

// ── Markdown setup ────────────────────────────────────────────────────────────

marked.use({
  gfm: true,
  breaks: true,
});

function renderMarkdown(text) {
  const div = document.createElement('div');
  div.className = 'prose';
  div.innerHTML = marked.parse(text);
  div.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
  return div;
}

// ── Tool icons ────────────────────────────────────────────────────────────────

const TOOL_ICONS = {
  web_search:   '🔍',
  filesystem:   '📁',
  http_request: '🌐',
  code_exec:    '⚙️',
  terminal:     '💻',
  ssh_exec:     '🖧',
};

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

function esc(str) {
  return String(str ?? '')
    .replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function timeLabel(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d)) return '';
  const diff = Date.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();
}

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Append a complete message bubble (used for history and user messages).
 * Assistant messages are rendered as markdown; user messages as plain text.
 * Returns the bubble element.
 */
export function appendMessage(el, role, content) {
  const wrap = document.createElement('div');
  wrap.className = `msg ${role}`;

  const bubble = document.createElement('div');
  bubble.className = 'bubble';

  if (role === 'assistant') {
    bubble.appendChild(renderMarkdown(content));
  } else {
    bubble.textContent = content;
  }

  const time = document.createElement('div');
  time.className = 'msg-time';
  time.textContent = timeLabel(new Date().toISOString());

  wrap.append(bubble, time);
  el.appendChild(wrap);
  return bubble;
}

/**
 * Called during streaming: bubble shows raw text with a cursor.
 * Returns the bubble element — caller appends deltas via textContent.
 */
export function appendStreamBubble(el) {
  const wrap = document.createElement('div');
  wrap.className = 'msg assistant';

  const bubble = document.createElement('div');
  bubble.className = 'bubble cursor';

  const time = document.createElement('div');
  time.className = 'msg-time';
  time.textContent = 'just now';

  wrap.append(bubble, time);
  el.appendChild(wrap);
  return bubble;
}

/**
 * Called on stream_end: replaces raw text with rendered markdown.
 */
export function finalizeStreamBubble(bubble, content) {
  bubble.classList.remove('cursor');
  bubble.textContent = '';
  bubble.appendChild(renderMarkdown(content));
}

/**
 * Tool call card with accordion for arguments + result.
 */
export function appendToolCall(el, event) {
  const icon    = TOOL_ICONS[event.tool] ?? '🔧';
  const success = event.success;

  // Format args as readable lines
  const argsLines = Object.entries(event.args ?? {})
    .map(([k, v]) => `<span class="arg-key">${esc(k)}</span><span class="arg-val">${esc(JSON.stringify(v))}</span>`)
    .join('');

  const card = document.createElement('details');
  card.className = `tool-card${success ? '' : ' error'}`;
  card.innerHTML = `
    <summary class="tool-header">
      <span class="tool-icon">${icon}</span>
      <span class="tool-name">${esc(event.tool)}</span>
      <span class="tool-status">${success ? '✓' : '✗'}</span>
    </summary>
    <div class="tool-body">
      ${argsLines ? `<div class="tool-args">${argsLines}</div>` : ''}
      <pre class="tool-result-pre">${esc(event.result)}</pre>
    </div>`;
  el.appendChild(card);
}

export function appendTypingIndicator(el) {
  removeTypingIndicator(el);
  const div = document.createElement('div');
  div.className = 'typing';
  div.id = 'typing-indicator';
  div.innerHTML = '<span></span><span></span><span></span>';
  el.appendChild(div);
}

export function removeTypingIndicator(el) {
  el.querySelector('#typing-indicator')?.remove();
}

export function appendError(el, message) {
  const div = document.createElement('div');
  div.className = 'msg-error';
  div.textContent = `Error: ${message}`;
  el.appendChild(div);
}

export function showEmptyState(el) {
  el.innerHTML = `
    <div class="empty-chat">
      <div class="icon">💬</div>
      <p>Start a new conversation</p>
    </div>`;
}

export function scrollToBottom(el) {
  el.scrollTop = el.scrollHeight;
}