← 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();
})();