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

import { marked }        from 'https://esm.sh/marked@12';
import hljs              from 'https://esm.sh/highlight.js@11';
import { esc, timeLabel, formatBytes } from './utils.js';

// ── 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:    '🔍',
  web_view:      '🌍',
  filesystem:    '📁',
  http_request:  '🌐',
  code_exec:     '⚙️',
  terminal:      '⚡',
  ssh_exec:      '🔌',
  image_view:    '🖼️',
  spawn_agent:   '🤖',
  memory_search: '🧠',
  memory_forget: '🗑️',
  write_tool:    '✏️',
  reload_tools:  '🔄',
  list_tools:    '📋',
  tool_manual:   '📖',
};

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

/** File icon by extension/name fallback. */
function fileIconByName(name) {
  const ext = (name.split('.').pop() ?? '').toLowerCase();
  if (['jpg','jpeg','png','gif','webp','bmp'].includes(ext)) return '🖼️';
  if (ext === 'svg') return '🎨';
  if (['mp4','mkv','avi','mov','webm'].includes(ext)) return '🎬';
  if (['mp3','wav','ogg','flac','m4a'].includes(ext)) return '🎵';
  if (ext === 'pdf') return '📄';
  if (['zip','tar','gz','bz2','7z','rar'].includes(ext)) return '🗜️';
  if (['json','xml','yaml','yml','toml','ini','env'].includes(ext)) return '⚙️';
  if (['js','ts','py','rs','go','java','c','cpp','h','rb','php','sh'].includes(ext)) return '💻';
  if (['md','txt','log','csv'].includes(ext)) return '📝';
  return '📎';
}

/** Parse and strip the [Uploaded files on disk:] block injected by the server. */
function extractFileBlock(content) {
  if (!content) return { text: content, files: null };
  const match = content.match(/\n\n\[Uploaded files on disk:\n([\s\S]*?)\]$/);
  if (!match) return { text: content, files: null };
  const text = content.slice(0, content.length - match[0].length) || null;
  const files = match[1]
    .split('\n')
    .filter(l => l.startsWith('- '))
    .map(l => {
      const m = l.match(/^- (.+?) → (.+)$/);
      return m ? { name: m[1], path: m[2] } : null;
    })
    .filter(Boolean);
  return { text, files: files.length ? files : null };
}

/**
 * Append a complete message bubble (used for history and user messages).
 * Assistant messages are rendered as markdown; user messages as plain text.
 * Pass images (array of base64 strings) to render them in the bubble.
 * Pass files (array of {name, size?, path?, content_type?}) to render file badges.
 * Returns the bubble element.
 */
export function appendMessage(el, role, content, images = null, timestamp = null, files = null) {
  const wrap = document.createElement('div');
  wrap.className = `msg ${role}`;

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

  // For user messages: extract file block from content if files not provided directly
  let displayContent = content;
  let displayFiles = files;
  if (role === 'user') {
    const extracted = extractFileBlock(content);
    displayContent = extracted.text;
    if (!displayFiles && extracted.files) displayFiles = extracted.files;
  }

  if (images?.length) {
    const imgStrip = document.createElement('div');
    imgStrip.className = 'bubble-images';
    for (const b64 of images) {
      const img = document.createElement('img');
      img.src = b64.startsWith('data:') ? b64 : `data:image/jpeg;base64,${b64}`;
      img.className = 'bubble-img';
      img.alt = 'attached image';
      imgStrip.appendChild(img);
    }
    bubble.appendChild(imgStrip);
  }

  if (role === 'assistant') {
    bubble.appendChild(renderMarkdown(content));
  } else if (displayContent) {
    const text = document.createElement('span');
    text.textContent = displayContent;
    bubble.appendChild(text);
  }

  if (displayFiles?.length) {
    const strip = document.createElement('div');
    strip.className = 'bubble-files';
    for (const f of displayFiles) {
      const badge = document.createElement('div');
      badge.className = 'file-badge';
      const icon = document.createElement('span');
      icon.className = 'file-badge-icon';
      icon.textContent = fileIconByName(f.name);
      const info = document.createElement('div');
      info.className = 'file-badge-info';
      const nameEl = document.createElement('div');
      nameEl.className = 'file-badge-name';
      nameEl.title = f.name;
      nameEl.textContent = f.name;
      info.appendChild(nameEl);
      if (f.size) {
        const sizeEl = document.createElement('div');
        sizeEl.className = 'file-badge-size';
        sizeEl.textContent = formatBytes(f.size);
        info.appendChild(sizeEl);
      }
      badge.append(icon, info);
      strip.appendChild(badge);
    }
    bubble.appendChild(strip);
  }

  const time = document.createElement('div');
  time.className = 'msg-time';
  time.textContent = timeLabel(timestamp ?? 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));
}

/** Build an args grid element from a {key:val} object. Returns null if empty. */
function buildArgsEl(args) {
  const entries = Object.entries(args ?? {});
  if (!entries.length) return null;
  const div = document.createElement('div');
  div.className = 'tool-args';
  div.innerHTML = entries
    .map(([k, v]) => `<span class="arg-key">${esc(k)}</span><span class="arg-val">${esc(JSON.stringify(v))}</span>`)
    .join('');
  return div;
}

/**
 * Create a pending tool card (spinner, no result yet).
 * Returns the card element — pass to finalizeToolCard() when done.
 */
export function appendPendingToolCard(el, event) {
  const icon = TOOL_ICONS[event.tool] ?? '🔧';
  const card = document.createElement('div');
  card.className = 'tool-card pending';

  const header = document.createElement('div');
  header.className = 'tool-header';
  header.innerHTML = `
    <span class="tool-icon">${icon}</span>
    <span class="tool-name">${esc(event.tool)}</span>
    <span class="tool-status"><span class="spinner-inline"></span></span>`;

  // For spawn_agent: open body immediately to show sub-agent log as it streams
  const body = document.createElement('div');
  body.className = event.tool === 'spawn_agent' ? 'tool-body tool-body-open' : 'tool-body';

  const argsEl = buildArgsEl(event.args);
  if (argsEl) body.appendChild(argsEl);

  if (event.tool === 'spawn_agent') {
    const log = document.createElement('div');
    log.className = 'subagent-log';
    body.appendChild(log);
    card._subagentLog = log;
  }

  card.append(header, body);
  el.appendChild(card);
  return card;
}

/**
 * Fill in a pending card with the completed result.
 */
export function finalizeToolCard(card, event) {
  const success = event.success;
  card.classList.remove('pending');
  if (!success) card.classList.add('error');

  const statusEl = card.querySelector('.tool-status');
  if (statusEl) statusEl.innerHTML = success ? '✓' : '✗';

  const body = card.querySelector('.tool-body');
  if (body) {
    body.classList.remove('tool-body-open');
    // Strip the "[Sub-agent result — ...]" reminder prefix before showing to user
    const result = event.result.replace(/^\[Sub-agent result[^\]]*\]\n\n/, '');
    const pre = document.createElement('pre');
    pre.className = 'tool-result-pre';
    pre.textContent = result;
    body.appendChild(pre);
  }

  const header = card.querySelector('.tool-header');
  if (header) header.addEventListener('click', () => card.classList.toggle('open'));
}

/**
 * Append a pending sub-agent step inside a spawn_agent card.
 * Returns the step element — pass to finalizeSubagentStep() when done.
 */
export function appendSubagentStep(card, event) {
  const log = card._subagentLog;
  if (!log) return null;
  const icon = TOOL_ICONS[event.tool] ?? '🔧';
  const step = document.createElement('div');
  step.className = 'subagent-step pending';
  step.innerHTML = `
    <span class="step-arrow">↳</span>
    <span class="step-icon">${icon}</span>
    <span class="step-name">${esc(event.tool)}</span>
    <span class="step-status"><span class="spinner-inline"></span></span>`;
  log.appendChild(step);
  return step;
}

/**
 * Mark a sub-agent step as complete.
 */
export function finalizeSubagentStep(step, event) {
  if (!step) return;
  step.classList.remove('pending');
  if (!event.success) step.classList.add('error');
  const statusEl = step.querySelector('.step-status');
  if (statusEl) statusEl.innerHTML = event.success ? '✓' : '✗';
}

/**
 * Tool call card from history — complete, collapsed by default.
 */
export function appendToolCall(el, event) {
  const icon    = TOOL_ICONS[event.tool] ?? '🔧';
  const success = event.success;

  const card = document.createElement('div');
  card.className = `tool-card${success ? '' : ' error'}`;

  const header = document.createElement('div');
  header.className = 'tool-header';
  header.innerHTML = `
    <span class="tool-icon">${icon}</span>
    <span class="tool-name">${esc(event.tool)}</span>
    <span class="tool-status">${success ? '✓' : '✗'}</span>`;

  const body = document.createElement('div');
  body.className = 'tool-body';
  const argsEl = buildArgsEl(event.args);
  if (argsEl) body.appendChild(argsEl);
  const pre = document.createElement('pre');
  pre.className = 'tool-result-pre';
  pre.textContent = event.result;
  body.appendChild(pre);

  header.addEventListener('click', () => card.classList.toggle('open'));
  card.append(header, body);
  el.appendChild(card);
}

/**
 * Create a thinking block (open by default, collapses on finalizeThinkingCard).
 * Returns {card, pre} — caller appends thinking text to pre.textContent.
 */
export function appendThinkingCard(el) {
  const card = document.createElement('div');
  card.className = 'thinking-card open';

  const header = document.createElement('div');
  header.className = 'thinking-header';
  header.innerHTML = '<span class="thinking-icon">💭</span><span class="thinking-label">Thinking…</span>';

  const body = document.createElement('div');
  body.className = 'thinking-body';

  const pre = document.createElement('pre');
  pre.className = 'thinking-pre';
  body.appendChild(pre);

  header.addEventListener('click', () => card.classList.toggle('open'));
  card.append(header, body);
  el.appendChild(card);
  return { card, pre };
}

/**
 * Called on thinking_end: update label and collapse the card.
 */
export function finalizeThinkingCard(card) {
  const label = card.querySelector('.thinking-label');
  if (label) label.textContent = 'Thought';
  card.classList.remove('open');
}

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

/**
 * Summary card — rendered for is_summary messages loaded from history.
 * Collapsed by default, click to expand.
 */
export function appendSummaryCard(el, content) {
  const text = content.replace(/^\[Context Summary\]\n?/, '');
  const card = document.createElement('div');
  card.className = 'summary-card';

  const header = document.createElement('div');
  header.className = 'summary-header';
  header.innerHTML = '<span class="summary-icon">📋</span><span>Context Summary</span>';

  const body = document.createElement('div');
  body.className = 'summary-body';
  body.appendChild(renderMarkdown(text));

  header.addEventListener('click', () => card.classList.toggle('open'));
  card.append(header, body);
  el.appendChild(card);
}

/**
 * Inline notice that compression ran — appended to the message list.
 */
export function appendCompressionNotice(el, before, after, summary) {
  const label = (before != null && after != null)
    ? `↑ Context compressed: ${before} → ${after} messages`
    : '↑ Older messages summarized to free context space';

  if (summary) {
    const details = document.createElement('details');
    details.className = 'compression-notice compression-notice--expandable';
    const sumEl = document.createElement('summary');
    sumEl.textContent = label;
    details.appendChild(sumEl);
    const body = document.createElement('div');
    body.className = 'compression-summary-body';
    body.innerHTML = marked.parse(summary);
    details.appendChild(body);
    el.appendChild(details);
  } else {
    const div = document.createElement('div');
    div.className = 'compression-notice';
    div.textContent = label;
    el.appendChild(div);
  }
}

/**
 * Intermediate text the model emitted alongside tool calls (not the final response).
 * Displayed as a subtle note between tool cards.
 */
export function appendAgentNote(el, text) {
  const div = document.createElement('div');
  div.className = 'agent-note';
  div.textContent = text;
  el.appendChild(div);
  return div;
}

/**
 * Sub-agent note — text emitted alongside tool calls inside run_ephemeral.
 * Rendered as a text line inside the spawn_agent card's subagent log.
 */
export function appendSubagentNote(card, text) {
  const log = card?._subagentLog;
  if (!log) return;
  const div = document.createElement('div');
  div.className = 'subagent-note';
  div.textContent = text;
  log.appendChild(div);
}

/**
 * Plan card — shown before tool calls when planning_enabled is set on the profile.
 * Collapsed by default (plan is complete when received, not streaming).
 */
export function appendPlanCard(el, plan) {
  const card = document.createElement('div');
  card.className = 'plan-card';

  const header = document.createElement('div');
  header.className = 'plan-header';
  header.innerHTML = '<span class="plan-icon">🗺️</span><span class="plan-label">Plan</span>';

  const body = document.createElement('div');
  body.className = 'plan-body';
  body.appendChild(renderMarkdown(plan));

  header.addEventListener('click', () => card.classList.toggle('open'));
  card.append(header, body);
  el.appendChild(card);
  return card;
}

/**
 * Thinking block from a tool-calling turn (complete() — full text, not streaming).
 * Rendered collapsed — content is already complete when received.
 */
export function appendTurnThinkingCard(el, thinking) {
  const card = document.createElement('div');
  card.className = 'thinking-card';  // collapsed by default (no 'open')

  const header = document.createElement('div');
  header.className = 'thinking-header';
  header.innerHTML = '<span class="thinking-icon">💭</span><span class="thinking-label">Thought</span>';

  const body = document.createElement('div');
  body.className = 'thinking-body';
  const pre = document.createElement('pre');
  pre.className = 'thinking-pre';
  pre.textContent = thinking;
  body.appendChild(pre);

  header.addEventListener('click', () => card.classList.toggle('open'));
  card.append(header, body);
  el.appendChild(card);
  return card;
}

/**
 * Sub-agent thinking block — collapsible block inside the spawn_agent subagent log.
 */
export function appendSubagentThinking(card, thinking) {
  const log = card?._subagentLog;
  if (!log) return;

  const block = document.createElement('details');
  block.className = 'subagent-thinking';

  const summary = document.createElement('summary');
  summary.textContent = '💭 Thought';

  const pre = document.createElement('pre');
  pre.className = 'subagent-thinking-pre';
  pre.textContent = thinking;

  block.append(summary, pre);
  log.appendChild(block);
}

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