← back to AbramsOS

public/js/chat.js

226 lines

// Nav-bar chat. Vanilla, no framework. Roster + specs come from
// /api/chat/registry (single source of truth). Both selections persist in
// localStorage. Session-only history (v1 needs no persistence). All rendered
// text goes through textContent — never innerHTML of model/user text.

(function () {
  const launcher = document.getElementById('aosChatLauncher');
  const panel = document.getElementById('aosChatPanel');
  if (!launcher || !panel) return;

  const modelSel = document.getElementById('aosChatModel');
  const agentSel = document.getElementById('aosChatAgent');
  const laneEl = document.getElementById('aosChatLane');
  const specEl = document.getElementById('aosChatSpec');
  const rosterEl = document.getElementById('aosChatRoster');
  const logEl = document.getElementById('aosChatLog');
  const form = document.getElementById('aosChatForm');
  const input = document.getElementById('aosChatInput');
  const sendBtn = document.getElementById('aosChatSend');
  const closeBtn = document.getElementById('aosChatClose');
  const allSpecsBtn = document.getElementById('aosChatAllSpecs');

  const MODEL_KEY = 'abramsos.chat.model';
  const AGENT_KEY = 'abramsos.chat.agent';

  let registry = { models: [], agents: [], defaults: {} };
  const history = []; // {role, content}

  function get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
  function set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} }

  function selectedModel() { return registry.models.find((m) => m.id === modelSel.value); }
  function selectedAgent() { return registry.agents.find((a) => a.id === agentSel.value); }

  function updateLane() {
    const m = selectedModel();
    if (!m) { laneEl.textContent = ''; return; }
    const local = m.visibility === 'local';
    laneEl.textContent = local ? '🔒 Private / local' : '☁ External / redacted';
    laneEl.classList.toggle('is-local', local);
    laneEl.classList.toggle('is-external', !local);
  }

  function fillSelect(sel, items, labelFn) {
    sel.innerHTML = '';
    for (const it of items) {
      const opt = document.createElement('option');
      opt.value = it.id;
      opt.textContent = labelFn(it);
      if (it.available === false) opt.disabled = true;
      sel.appendChild(opt);
    }
  }

  function specLines(pairs) {
    const wrap = document.createElement('div');
    for (const [k, v] of pairs) {
      const row = document.createElement('div');
      row.className = 'aos-spec-row';
      const key = document.createElement('span'); key.className = 'aos-spec-k'; key.textContent = k;
      const val = document.createElement('span'); val.className = 'aos-spec-v'; val.textContent = v;
      row.appendChild(key); row.appendChild(val);
      wrap.appendChild(row);
    }
    return wrap;
  }

  function renderModelSpec(target, m) {
    const h = document.createElement('div'); h.className = 'aos-spec-title'; h.textContent = m.label;
    target.appendChild(h);
    target.appendChild(specLines([
      ['Provider', m.provider],
      ['Context', m.contextWindow || '—'],
      ['Speed', m.speed || '—'],
      ['Cost', m.cost || '—'],
      ['Data', m.dataNote || '—'],
      ['Available', m.available === false ? 'not pulled' : 'yes'],
    ]));
  }

  function renderAgentSpec(target, a) {
    const h = document.createElement('div'); h.className = 'aos-spec-title'; h.textContent = (a.icon ? a.icon + ' ' : '') + a.label;
    target.appendChild(h);
    target.appendChild(specLines([
      ['Data scope', a.scope || 'none (general)'],
      ['Data', a.dataNote || '—'],
      ['Role', (a.system || '').slice(0, 220) + ((a.system || '').length > 220 ? '…' : '')],
    ]));
  }

  function showSpec(kind) {
    panel.hidden = false; // triggered from the top-bar ⓘ — make sure the panel is open
    rosterEl.hidden = true;
    specEl.hidden = false;
    specEl.innerHTML = '';
    if (kind === 'model') { const m = selectedModel(); if (m) renderModelSpec(specEl, m); }
    else { const a = selectedAgent(); if (a) renderAgentSpec(specEl, a); }
  }

  function toggleAllSpecs() {
    panel.hidden = false; // triggered from the top-bar "All specs" — open the panel
    specEl.hidden = true;
    if (!rosterEl.hidden) { rosterEl.hidden = true; return; }
    rosterEl.hidden = false;
    rosterEl.innerHTML = '';
    const mh = document.createElement('div'); mh.className = 'aos-spec-section'; mh.textContent = 'Models';
    rosterEl.appendChild(mh);
    for (const m of registry.models) { const c = document.createElement('div'); c.className = 'aos-spec-card'; renderModelSpec(c, m); rosterEl.appendChild(c); }
    const ah = document.createElement('div'); ah.className = 'aos-spec-section'; ah.textContent = 'Agents';
    rosterEl.appendChild(ah);
    for (const a of registry.agents) { const c = document.createElement('div'); c.className = 'aos-spec-card'; renderAgentSpec(c, a); rosterEl.appendChild(c); }
  }

  const ASSISTANT_DISCLAIMER = 'Informational only — AbramsOS takes no action.';

  function bubble(role, text, foot) {
    const b = document.createElement('div');
    b.className = 'aos-bubble aos-' + role;
    const body = document.createElement('div'); body.className = 'aos-bubble-body'; body.textContent = text;
    b.appendChild(body);
    if (foot) { const f = document.createElement('div'); f.className = 'aos-bubble-foot'; f.textContent = foot; b.appendChild(f); }
    // Persistent disclaimer on every assistant bubble — v1 is read/answer-only.
    if (role === 'assistant') {
      const d = document.createElement('div');
      d.className = 'aos-bubble-disclaimer';
      d.textContent = ASSISTANT_DISCLAIMER;
      b.appendChild(d);
    }
    logEl.appendChild(b);
    logEl.scrollTop = logEl.scrollHeight;
    return b;
  }

  // Interstitial confirm before the FIRST send of health data to an EXTERNAL
  // model. Fires once per (external-model × vitals-agent) session pairing.
  const externalHealthConfirmed = new Set();
  function confirmExternalHealth(model, agent) {
    if (!model || model.visibility !== 'external') return true;
    if (!agent || agent.scope !== 'vitals') return true;
    const key = model.id;
    if (externalHealthConfirmed.has(key)) return true;
    const ok = window.confirm(
      'You are about to send health data to ' + model.label + ', an EXTERNAL model. ' +
      'Personal identifiers are redacted first, but the readings themselves leave this device. ' +
      'For sensitive health data a local (private) model is recommended.\n\nContinue with ' + model.label + '?'
    );
    if (ok) externalHealthConfirmed.add(key);
    return ok;
  }

  async function send(message) {
    const model = selectedModel();
    const agent = selectedAgent();
    if (!model || !agent) return;
    if (!confirmExternalHealth(model, agent)) return;
    bubble('user', message);
    history.push({ role: 'user', content: message });
    const thinking = bubble('assistant', '…thinking');
    sendBtn.disabled = true;
    try {
      const res = await fetch(location.origin + '/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message, modelId: model.id, agentId: agent.id, history: history.slice(-10) }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
      thinking.querySelector('.aos-bubble-body').textContent = data.reply;
      const foot = 'via ' + data.modelId + ' · ' + data.lane +
        (data.redactionApplied ? ' · ' + (data.redactions || 0) + ' fields redacted' : '');
      const f = document.createElement('div'); f.className = 'aos-bubble-foot'; f.textContent = foot;
      thinking.insertBefore(f, thinking.querySelector('.aos-bubble-disclaimer'));
      history.push({ role: 'assistant', content: data.reply });
    } catch (err) {
      thinking.classList.add('aos-error');
      thinking.querySelector('.aos-bubble-body').textContent = 'Error: ' + err.message;
    } finally {
      sendBtn.disabled = false;
      logEl.scrollTop = logEl.scrollHeight;
    }
  }

  async function boot() {
    try {
      // Resolve against location.origin (never carries credentials) so a
      // user:pass@host bookmark can't poison document.baseURI and throw
      // "Request cannot be constructed from a URL that includes credentials".
      const res = await fetch(location.origin + '/api/chat/registry');
      registry = await res.json();
    } catch (_) {
      return;
    }
    fillSelect(modelSel, registry.models, (m) => m.label + (m.available === false ? ' (not pulled)' : ''));
    fillSelect(agentSel, registry.agents, (a) => (a.icon ? a.icon + ' ' : '') + a.label);

    const savedModel = get(MODEL_KEY);
    const savedAgent = get(AGENT_KEY);
    const modelOk = savedModel && registry.models.some((m) => m.id === savedModel && m.available !== false);
    modelSel.value = modelOk ? savedModel : (registry.defaults.modelId || registry.models[0]?.id || '');
    agentSel.value = (savedAgent && registry.agents.some((a) => a.id === savedAgent))
      ? savedAgent : (registry.defaults.agentId || registry.agents[0]?.id || '');

    updateLane();

    modelSel.addEventListener('change', () => { set(MODEL_KEY, modelSel.value); updateLane(); if (!specEl.hidden) showSpec('model'); });
    agentSel.addEventListener('change', () => { set(AGENT_KEY, agentSel.value); if (!specEl.hidden) showSpec('agent'); });
    document.querySelectorAll('.aos-chat-info').forEach((b) => b.addEventListener('click', () => showSpec(b.dataset.spec)));
    allSpecsBtn.addEventListener('click', toggleAllSpecs);
  }

  launcher.addEventListener('click', () => { panel.hidden = !panel.hidden; if (!panel.hidden) input.focus(); });
  closeBtn.addEventListener('click', () => { panel.hidden = true; });
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const msg = input.value.trim();
    if (!msg) return;
    input.value = '';
    send(msg);
  });
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
  });

  boot();
})();