Files
Cortex-Inara/cortex/static/app.js
Scott Idem 3b3456600a feat: store and display backend + host metadata on chat messages
Each assistant message in the session JSON now carries:
  backend, backend_label, host (platform.node())

These fields are shown as model tags in the UI — on live responses and
when loading session history. Session log entries (sessions/YYYY-MM-DD.md)
include the backend label and host in the turn header.

The local (OpenAI-compat) backend strips non-standard fields before
sending messages to the API so extra fields don't leak upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:16:48 -04:00

1720 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const sessionEl = document.getElementById('session-id');
const headerEmoji = document.querySelector('.header-emoji');
const backendToggle = document.getElementById('backend-toggle');
const sessionsBtn = document.getElementById('sessions-btn');
const sessionsPanel = document.getElementById('sessions-panel');
const heightSel = document.getElementById('height-sel');
const enterToggle = document.getElementById('enter-toggle');
const stopBtn = document.getElementById('stop');
const mode_select_btn_el = document.getElementById('mode-select-btn');
const mode_dropdown_el = document.getElementById('mode-dropdown');
const mode_icon_el = document.getElementById('mode-icon');
const mode_label_el = document.getElementById('mode-label');
const note_vis_btn_el = document.getElementById('note-vis-btn');
const settings_btn_el = document.getElementById('settings-btn');
const settings_dd_el = document.getElementById('settings-dropdown');
const sessionsBackdrop = document.getElementById('sessions-backdrop');
// ── Close all panels/dropdowns (mutual exclusion) ─────────────
function closeAllPanels() {
if (mode_dropdown_el) mode_dropdown_el.classList.remove('open');
if (settings_dd_el) settings_dd_el.classList.remove('open');
if (sessionsPanel) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); }
const pd = document.getElementById('persona-dropdown');
if (pd) pd.classList.remove('open');
}
// ── Toasts ────────────────────────────────────────────────────
const toastContainer = document.getElementById('toast-container');
function showToast(message, type = 'info', duration = 2500) {
const el = document.createElement('div');
el.className = 'toast' + (type !== 'info' ? ' ' + type : '');
el.textContent = message;
toastContainer.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('show'));
});
setTimeout(() => {
el.classList.remove('show');
el.addEventListener('transitionend', () => el.remove(), { once: true });
}, duration);
}
// ── Syntax highlighting ───────────────────────────────────────
function highlight_code(container) {
if (typeof hljs === 'undefined') return;
container.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
}
// ── Utility helpers ───────────────────────────────────────────
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Lucide icon helpers ───────────────────────────────────────
function icon_html(name, size = 16) {
return `<svg data-lucide="${name}" width="${size}" height="${size}" class="btn-icon"></svg>`;
}
function render_icons() { if (window.lucide) lucide.createIcons(); }
// User/persona injected by the server at /{user}/{persona}
const CORTEX_USER = (window.CORTEX_CONFIG || {}).user || 'scott';
const CORTEX_PERSONA = (window.CORTEX_CONFIG || {}).persona || 'inara';
const CORTEX_EMOJI = (window.CORTEX_CONFIG || {}).emoji || '✨';
const personaLabel = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
const _fileParams = `user=${encodeURIComponent(CORTEX_USER)}&persona=${encodeURIComponent(CORTEX_PERSONA)}`;
if (headerEmoji) headerEmoji.textContent = CORTEX_EMOJI;
// Set favicon to persona emoji
{
const favicon = document.querySelector("link[rel='icon']");
if (favicon && CORTEX_EMOJI) {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${CORTEX_EMOJI}</text></svg>`;
favicon.href = `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
}
// Wire help link to preserve current persona on return
const helpLink = document.getElementById('help-link');
if (helpLink) helpLink.href = `/help?persona=${encodeURIComponent(CORTEX_PERSONA)}`;
let sessionId = null;
let primaryBackend = null; // null = auto / role-based routing
let activeController = null;
let currentHistory = []; // mirrors backend session [{role, content}, ...]
let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates
// ── Session persistence ───────────────────────────────────────
// Survives page navigation (help, settings, etc.) within the same browser.
// Expires after SESSION_TTL_MS of inactivity.
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const _sid_key = `cx_sid_${CORTEX_USER}_${CORTEX_PERSONA}`;
const _sid_ts_key = `cx_sid_ts_${CORTEX_USER}_${CORTEX_PERSONA}`;
function persist_session() {
if (!sessionId) return;
localStorage.setItem(_sid_key, sessionId);
localStorage.setItem(_sid_ts_key, String(Date.now()));
}
function clear_stored_session() {
localStorage.removeItem(_sid_key);
localStorage.removeItem(_sid_ts_key);
}
function get_stored_session() {
const id = localStorage.getItem(_sid_key);
const ts = parseInt(localStorage.getItem(_sid_ts_key) || '0', 10);
if (!id || Date.now() - ts > SESSION_TTL_MS) return null;
return id;
}
// ── Enter toggle ─────────────────────────────────────────────
// Default: Ctrl+Enter sends. Stored in localStorage.
let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false';
function updateEnterToggleUI() {
enterToggle.textContent = ctrlEnterMode ? '⌃↵' : '↵';
enterToggle.title = ctrlEnterMode
? 'Ctrl+Enter sends — click for Enter mode'
: 'Enter sends — click for Ctrl+Enter mode';
updateInputPlaceholder();
}
enterToggle.addEventListener('click', () => {
ctrlEnterMode = !ctrlEnterMode;
localStorage.setItem('ctrlEnterSend', ctrlEnterMode);
updateEnterToggleUI();
});
// ── Textarea height ──────────────────────────────────────────
let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120');
function syncHeight() {
inputEl.style.height = 'auto';
inputEl.style.maxHeight = maxHeight + 'px';
const sh = inputEl.scrollHeight;
inputEl.style.height = Math.min(sh, maxHeight) + 'px';
}
heightSel.value = String(maxHeight);
heightSel.addEventListener('change', () => {
maxHeight = parseInt(heightSel.value);
localStorage.setItem('maxHeight', maxHeight);
syncHeight();
});
// ── Input mode — dropdown select with MRU ordering ──────────
const MODES = {
chat: { icon: 'message-circle', label: 'Chat' },
note: { icon: 'pencil', label: 'Note' },
otr: { icon: 'lock', label: 'OTR' },
agent: { icon: 'bot', label: 'Agent' },
};
const send_defs = {
chat: { icon: 'arrow-up', label: 'Send' },
note: { icon: 'pencil', label: 'Note' },
otr: { icon: 'arrow-up', label: 'Send' },
agent: { icon: 'zap', label: 'Run' },
};
let current_mode = localStorage.getItem('current_mode') || 'chat';
let note_public = false;
// MRU list — most recent first; used to sort dropdown options
let mode_mru = JSON.parse(localStorage.getItem('mode_mru') || '["chat","note","otr","agent"]');
function push_mru(mode) {
mode_mru = [mode, ...mode_mru.filter(m => m !== mode)];
localStorage.setItem('mode_mru', JSON.stringify(mode_mru));
}
function set_mode(mode) {
push_mru(mode);
current_mode = mode;
localStorage.setItem('current_mode', current_mode);
close_mode_dropdown();
update_mode_ui();
inputEl.focus();
}
function open_mode_dropdown() {
closeAllPanels();
// Build options in MRU order (least recent at top, most recent at bottom)
// — bottom is visually closest to the button since dropdown opens upward
const ordered = [...mode_mru].reverse();
mode_dropdown_el.innerHTML = '';
ordered.forEach(mode => {
const m = MODES[mode];
const btn = document.createElement('button');
btn.className = 'mode-option' + (mode === current_mode ? ' current' : '');
btn.innerHTML =
`<span class="opt-icon">${icon_html(m.icon, 15)}</span>${m.label}`
+ (mode === current_mode ? '<span class="opt-check">✓</span>' : '');
btn.addEventListener('click', () => set_mode(mode));
mode_dropdown_el.appendChild(btn);
});
mode_dropdown_el.classList.add('open');
render_icons();
}
function close_mode_dropdown() {
mode_dropdown_el.classList.remove('open');
}
mode_select_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
if (mode_dropdown_el.classList.contains('open')) close_mode_dropdown();
else open_mode_dropdown();
});
document.addEventListener('click', (e) => {
if (!mode_dropdown_el.contains(e.target) && e.target !== mode_select_btn_el) {
close_mode_dropdown();
}
});
function update_mode_ui() {
const m = MODES[current_mode];
const sd = send_defs[current_mode] || send_defs.chat;
// Update trigger button
mode_icon_el.innerHTML = icon_html(m.icon, 15);
mode_label_el.textContent = m.label;
mode_select_btn_el.className = current_mode === 'chat'
? '' : `mode-${current_mode}`;
// Note visibility sub-button
const is_note = current_mode === 'note';
note_vis_btn_el.style.display = is_note ? '' : 'none';
note_vis_btn_el.textContent = note_public ? 'pub' : 'prv';
note_vis_btn_el.classList.toggle('pub', note_public);
// Textarea mode classes
inputEl.classList.toggle('mode-note', current_mode === 'note');
inputEl.classList.toggle('public', current_mode === 'note' && note_public);
inputEl.classList.toggle('mode-otr', current_mode === 'otr');
inputEl.classList.toggle('mode-agent', current_mode === 'agent');
// Send button label + icon
sendBtn.innerHTML = icon_html(sd.icon) + ' ' + sd.label;
render_icons();
updateInputPlaceholder();
}
function updateInputPlaceholder() {
if (current_mode === 'note') {
inputEl.placeholder = note_public
? 'Public note — LLM sees this next turn…'
: 'Private note — only you see this…';
} else if (current_mode === 'agent') {
inputEl.placeholder = ctrlEnterMode
? `Task for ${personaLabel}… (Gemini tool loop — Ctrl+Enter to run)`
: `Task for ${personaLabel}… (Gemini tool loop)`;
} else if (current_mode === 'otr') {
inputEl.placeholder = 'Off the record — not logged or distilled…';
} else {
inputEl.placeholder = ctrlEnterMode
? `Message ${personaLabel}… (Ctrl+Enter to send)`
: `Message ${personaLabel}`;
}
}
// Note private/public sub-toggle
note_vis_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
note_public = !note_public;
update_mode_ui();
});
// ── Settings dropdown ─────────────────────────────────────────
settings_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = settings_dd_el.classList.contains('open');
closeAllPanels();
if (!isOpen) settings_dd_el.classList.add('open');
});
document.addEventListener('click', (e) => {
if (!settings_dd_el.contains(e.target) && e.target !== settings_btn_el) {
settings_dd_el.classList.remove('open');
}
});
// ── Persona name + switcher ──────────────────────────────────
const personaNameEl = document.getElementById('persona-name');
const personaDropEl = document.getElementById('persona-dropdown');
const personaSwitcher = document.getElementById('persona-switcher');
if (personaNameEl && CORTEX_PERSONA) {
personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
}
// Load persona list and build dropdown
async function loadPersonaSwitcher() {
try {
const res = await fetch('/api/personas');
if (!res.ok) return;
const data = await res.json();
const personas = data.personas || [];
if (personas.length === 0) return;
personaDropEl.innerHTML = '';
personas.forEach(p => {
const a = document.createElement('a');
a.href = `/${CORTEX_USER}/${p}`;
a.textContent = p.charAt(0).toUpperCase() + p.slice(1);
if (p === CORTEX_PERSONA) a.classList.add('active');
personaDropEl.appendChild(a);
});
const divider = document.createElement('div');
divider.className = 'pd-divider';
personaDropEl.appendChild(divider);
const addLink = document.createElement('a');
addLink.href = '/setup/persona';
addLink.className = 'pd-add';
addLink.textContent = '+ New persona';
personaDropEl.appendChild(addLink);
} catch (_) {}
}
loadPersonaSwitcher();
// Toggle dropdown on click
if (personaSwitcher) {
personaSwitcher.addEventListener('click', (e) => {
if (personaDropEl.children.length === 0) return;
const isOpen = personaDropEl.classList.contains('open');
closeAllPanels();
if (!isOpen) personaDropEl.classList.add('open');
e.stopPropagation();
});
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
}
// ── Backend toggle ───────────────────────────────────────────
// null = "auto" — uses role-based routing from model registry
// 'claude' / 'gemini' / 'local' = explicit override
// On load only fetch local_model hint; don't override primaryBackend default (null)
fetch('/backend').then(r => r.json()).then(d => {
if (backendModelHint && d.local_model) {
// Pre-fill hint in case user is already in local mode
backendModelHint.textContent = d.local_model.label || d.local_model.model_name;
}
});
const BACKEND_CYCLE = [null, 'claude', 'gemini', 'local'];
const BACKEND_CLASS = { claude: '', gemini: 'mem-on', local: 'local-on' };
const backendModelHint = document.getElementById('backend-model-hint');
function setBackendUI(backend, localModel) {
primaryBackend = backend;
backendToggle.textContent = backend === null ? 'auto' : backend;
const extra = backend === null ? '' : (BACKEND_CLASS[backend] || '');
backendToggle.className = 'ctx-btn' + (extra ? ' ' + extra : '');
if (backendModelHint) {
if (backend === 'local' && localModel) {
backendModelHint.textContent = localModel.label || localModel.model_name;
backendModelHint.style.display = '';
} else {
backendModelHint.textContent = '';
backendModelHint.style.display = 'none';
}
}
}
// Initialize to auto mode
setBackendUI(null, null);
backendToggle.addEventListener('click', async () => {
const idx = BACKEND_CYCLE.indexOf(primaryBackend);
const next = BACKEND_CYCLE[(idx + 1) % BACKEND_CYCLE.length];
if (next === null) {
// Auto: role-based routing — no server call needed
setBackendUI(null, null);
addMessage('system', 'Backend: auto (role-based routing)');
} else {
const res = await fetch('/backend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ primary: next }),
});
const d = await res.json();
setBackendUI(next, d.local_model);
addMessage('system', `Backend: ${next} (fallback: ${d.fallback})`);
}
});
// ── Sessions panel ───────────────────────────────────────────
sessionsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (sessionsPanel.classList.contains('open')) {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
return;
}
closeAllPanels();
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
sessionsPanel.classList.add('open');
sessionsBackdrop.classList.add('open');
});
sessionsBackdrop.addEventListener('click', () => {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
});
document.addEventListener('click', (e) => {
if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
}
});
// session_id → friendly name (populated on each panel render)
const sessionNames = new Map();
function renderPanel(sessions) {
sessionsPanel.innerHTML = '';
sessionNames.clear();
const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => {
sessionId = null;
clear_stored_session();
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
addMessage('system', 'New session');
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
inputEl.focus();
});
sessionsPanel.appendChild(newItem);
if (!sessions.length) {
const empty = makeItem('', 'No sessions yet', '');
empty.style.cursor = 'default';
empty.style.color = 'var(--muted)';
sessionsPanel.appendChild(empty);
return;
}
for (const s of sessions) {
const displayName = s.name || s.session_id;
sessionNames.set(s.session_id, displayName);
const item = makeItem(
s.session_id === sessionId ? 'active' : '',
displayName,
`${s.message_count} msgs · ${timeAgo(s.updated)}`
);
item.addEventListener('click', () => resumeSession(s.session_id));
// Rename button (✎)
const renameBtn = document.createElement('button');
renameBtn.className = 'session-rename-btn';
renameBtn.textContent = '✎';
renameBtn.title = 'Rename session';
renameBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const labelEl = item.querySelector('.session-id');
const current = s.name || '';
const input = document.createElement('input');
input.className = 'session-rename-input';
input.value = current;
input.placeholder = s.session_id;
labelEl.replaceWith(input);
input.focus();
input.select();
async function commitRename() {
const newName = input.value.trim();
await fetch(`/sessions/${s.session_id}?${_fileParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
// Update status bar if this is the active session
if (sessionId === s.session_id) {
sessionEl.textContent = `session: ${newName || s.session_id}`;
}
if (newName) showToast('Session renamed', 'success');
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
if (e.key === 'Escape') { renderPanel(sessions); }
});
input.addEventListener('blur', commitRename);
});
item.appendChild(renameBtn);
const delBtn = document.createElement('button');
delBtn.className = 'session-delete-btn';
delBtn.textContent = '×';
delBtn.title = 'Delete session';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
if (sessionId === s.session_id) {
sessionId = null;
clear_stored_session();
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
showToast('Session deleted');
}
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
});
item.appendChild(delBtn);
sessionsPanel.appendChild(item);
}
}
function makeItem(cls, label, meta) {
const item = document.createElement('div');
item.className = 'session-item' + (cls ? ' ' + cls : '');
const idEl = document.createElement('span');
idEl.className = cls === 'new' ? '' : 'session-id';
idEl.textContent = label;
item.appendChild(idEl);
if (meta) {
const metaEl = document.createElement('span');
metaEl.className = 'session-meta';
metaEl.textContent = meta;
item.appendChild(metaEl);
}
return item;
}
async function resumeSession(id, silent = false) {
talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
const res = await fetch(`/history/${id}?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
messagesEl.innerHTML = '';
sessionId = id;
sessionEl.textContent = `session: ${sessionNames.get(id) || id}`;
currentHistory = [];
for (let i = 0; i < data.messages.length; i++) {
const msg = data.messages[i];
const role = msg.role === 'user' ? 'user' : 'assistant';
currentHistory.push({ role, content: msg.content });
const msgDiv = addMessage(role, msg.content);
attachHistoryControls(msgDiv, i);
if (role === 'assistant' && (msg.backend_label || msg.backend)) {
const modelTag = document.createElement('div');
modelTag.className = 'model-tag';
const label = msg.backend_label || msg.backend;
modelTag.textContent = msg.host ? `${label} · ${msg.host}` : label;
msgDiv.appendChild(modelTag);
}
}
if (!silent) addMessage('system', `Resumed session ${id}`);
scrollToBottom();
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
inputEl.focus();
persist_session();
}
function timeAgo(iso) {
if (!iso) return '?';
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
// ── Scroll helpers ────────────────────────────────────────────
// Only auto-scroll when the user is already near the bottom (within 80px).
// Explicit user actions (send, resume) call scrollToBottom() directly.
function isNearBottom() {
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80;
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ── Chat ─────────────────────────────────────────────────────
// Returns the inner .message div. For user/assistant, wraps in .msg-wrapper.
function addMessage(role, text) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant' && typeof marked !== 'undefined') {
div.dataset.raw = text;
div.innerHTML = marked.parse(text);
highlight_code(div);
div.querySelectorAll('a').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
div.appendChild(makeCopyBtn(div));
} else if (role === 'note-private' || role === 'note-public') {
const label = document.createElement('span');
label.className = 'note-label';
label.textContent = role === 'note-private' ? '◦ private note' : '◦ context note';
const content = document.createElement('span');
content.className = 'note-content';
content.textContent = text;
div.appendChild(label);
div.appendChild(content);
} else {
div.dataset.raw = text;
div.textContent = text;
div.appendChild(makeCopyBtn(div));
}
// Wrap user/assistant messages so action buttons can be attached
const baseRole = role.split(' ')[0]; // 'user' or 'assistant' (strips 'thinking' etc)
if (baseRole === 'user' || baseRole === 'assistant') {
const wrapper = document.createElement('div');
wrapper.className = `msg-wrapper ${baseRole}`;
wrapper.appendChild(div);
const actions = document.createElement('div');
actions.className = 'msg-actions';
wrapper.appendChild(actions);
messagesEl.appendChild(wrapper);
} else {
messagesEl.appendChild(div);
}
if (isNearBottom()) scrollToBottom();
return div;
}
// Wire edit/delete controls onto a message div (must already be in a .msg-wrapper).
// histIdx is the index into currentHistory. Reads wrapper.dataset.histIdx at click time
// so re-indexing after deletions is automatically picked up.
function attachHistoryControls(msgDiv, histIdx) {
const wrapper = msgDiv.parentElement;
if (!wrapper || !wrapper.classList.contains('msg-wrapper')) return;
wrapper.dataset.histIdx = histIdx;
const actionsDiv = wrapper.querySelector('.msg-actions');
if (!actionsDiv) return;
actionsDiv.innerHTML = '';
const editBtn = document.createElement('button');
editBtn.className = 'msg-act-btn';
editBtn.innerHTML = icon_html('pencil', 12) + ' edit';
editBtn.addEventListener('click', () => {
startEdit(msgDiv);
});
const delBtn = document.createElement('button');
delBtn.className = 'msg-act-btn del';
delBtn.innerHTML = icon_html('trash-2', 12) + ' del';
delBtn.addEventListener('click', () => {
deleteMsg(wrapper);
});
actionsDiv.appendChild(editBtn);
actionsDiv.appendChild(delBtn);
render_icons();
}
// After any currentHistory splice, renumber all wrapper data-hist-idx attributes.
function reIndexWrappers() {
messagesEl.querySelectorAll('.msg-wrapper').forEach((w, i) => {
w.dataset.histIdx = i;
});
}
function startEdit(msgDiv) {
const wrapper = msgDiv.parentElement;
const idx = parseInt(wrapper.dataset.histIdx);
const role = msgDiv.classList.contains('user') ? 'user' : 'assistant';
const originalText = currentHistory[idx]?.content
|| msgDiv.dataset.raw
|| msgDiv.textContent;
// Lock the current rendered size so the bubble doesn't collapse when we clear it
const lockedW = msgDiv.offsetWidth;
const lockedH = msgDiv.offsetHeight;
msgDiv.style.minWidth = lockedW + 'px';
msgDiv.style.minHeight = lockedH + 'px';
const actionsDiv = wrapper.querySelector('.msg-actions');
if (actionsDiv) actionsDiv.style.display = 'none';
const ta = document.createElement('textarea');
ta.className = 'edit-textarea';
ta.value = originalText;
ta.rows = Math.min(originalText.split('\n').length + 1, 12);
const saveBtn = document.createElement('button');
saveBtn.innerHTML = icon_html('check', 13) + ' Save';
saveBtn.className = 'edit-save-btn';
const cancelBtn = document.createElement('button');
cancelBtn.innerHTML = icon_html('x', 13) + ' Cancel';
cancelBtn.className = 'edit-cancel-btn';
const btnRow = document.createElement('div');
btnRow.className = 'edit-btns';
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
render_icons();
msgDiv.innerHTML = '';
msgDiv.appendChild(ta);
msgDiv.appendChild(btnRow);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
function unlock() {
msgDiv.style.minWidth = '';
msgDiv.style.minHeight = '';
if (actionsDiv) actionsDiv.style.display = '';
}
function restore() {
setMessageText(msgDiv, role, originalText);
unlock();
}
function save() {
const newText = ta.value.trim();
if (!newText) return;
const currentIdx = parseInt(wrapper.dataset.histIdx);
currentHistory[currentIdx].content = newText;
setMessageText(msgDiv, role, newText);
unlock();
syncHistory();
}
saveBtn.addEventListener('click', save);
cancelBtn.addEventListener('click', restore);
ta.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
}
function deleteMsg(wrapper) {
const idx = parseInt(wrapper.dataset.histIdx);
currentHistory.splice(idx, 1);
wrapper.remove();
reIndexWrappers();
syncHistory();
}
async function syncHistory() {
if (!sessionId) return;
try {
await fetch(`/history/${sessionId}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: currentHistory }),
});
} catch (err) {
console.error('syncHistory failed:', err);
}
}
function setMessageText(div, role, text) {
if (role === 'assistant' && typeof marked !== 'undefined') {
div.dataset.raw = text;
div.innerHTML = marked.parse(text);
highlight_code(div);
div.querySelectorAll('a').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
div.appendChild(makeCopyBtn(div));
} else {
div.textContent = text;
}
}
// ── Agent tool-call step cards ────────────────────────────────
function renderToolCalls(toolCalls, beforeEl) {
if (!toolCalls || toolCalls.length === 0) return;
const container = document.createElement('div');
container.className = 'tool-calls-container';
for (const tc of toolCalls) {
const details = document.createElement('details');
details.className = 'tool-call';
// Summary: name + first arg value snippet
const args = tc.args || {};
const argKeys = Object.keys(args);
let argSnippet = '';
if (argKeys.length > 0) {
const firstVal = String(args[argKeys[0]]);
argSnippet = firstVal.length > 60 ? firstVal.slice(0, 60) + '…' : firstVal;
}
const summary = document.createElement('summary');
const nameSpan = document.createElement('span');
nameSpan.className = 'tc-name';
nameSpan.textContent = tc.tool;
summary.appendChild(nameSpan);
if (argSnippet) {
const snippetSpan = document.createElement('span');
snippetSpan.className = 'tc-snippet';
snippetSpan.textContent = argSnippet;
summary.appendChild(snippetSpan);
}
details.appendChild(summary);
// Expanded body
const body = document.createElement('div');
body.className = 'tc-body';
if (argKeys.length > 0) {
const sec = document.createElement('div');
sec.className = 'tc-section';
const lbl = document.createElement('span');
lbl.className = 'tc-label';
lbl.textContent = 'args';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(args, null, 2);
sec.appendChild(lbl);
sec.appendChild(pre);
body.appendChild(sec);
}
const resultStr = tc.result || '';
const truncated = resultStr.length > 400;
const sec2 = document.createElement('div');
sec2.className = 'tc-section';
const lbl2 = document.createElement('span');
lbl2.className = 'tc-label';
lbl2.textContent = 'result';
const pre2 = document.createElement('pre');
pre2.textContent = truncated ? resultStr.slice(0, 400) + '\n…[truncated]' : resultStr;
sec2.appendChild(lbl2);
sec2.appendChild(pre2);
body.appendChild(sec2);
details.appendChild(body);
container.appendChild(details);
}
beforeEl.parentElement.insertBefore(container, beforeEl);
}
function makeCopyBtn(div) {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.innerHTML = icon_html('copy', 12) + ' copy';
render_icons();
btn.addEventListener('click', (e) => {
e.stopPropagation();
const text = div.dataset.raw || '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
showToast('Copied to clipboard', 'success', 1800);
btn.innerHTML = icon_html('check', 12) + ' copied';
render_icons();
btn.classList.add('copied');
setTimeout(() => {
btn.innerHTML = icon_html('copy', 12) + ' copy';
btn.classList.remove('copied');
render_icons();
}, 1500);
});
return btn;
}
async function addNote() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
syncHeight();
if (!note_public) {
// Private: UI only, never sent to backend
addMessage('note-private', text);
return;
}
// Public: show in UI and persist to session so LLM sees it next turn
if (!sessionId) {
addMessage('system', 'Start a conversation first before adding a public note.');
return;
}
addMessage('note-public', text);
try {
const res = await fetch('/note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, note: text }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
showToast(`Note save failed: ${err.message}`, 'error');
}
}
stopBtn.addEventListener('click', () => {
if (activeController) activeController.abort();
});
// ── Chat fetch + SSE handler ─────────────────────────────────
// Extracted so the retry button can call it without re-adding the
// user message to the DOM or currentHistory.
async function _doSend(payload, thinkingDiv) {
try {
const res = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: activeController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6));
if (data.type === 'keepalive') continue;
if (data.type === 'response') {
sessionId = data.session_id;
sessionEl.textContent = `session: ${sessionId}`;
persist_session();
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
// Model tag — always shown, amber if fallback was used
const modelTag = document.createElement('div');
modelTag.className = 'model-tag' + (data.fallback_used ? ' fallback' : '');
const label = data.backend_label || data.backend || '';
const hostSuffix = data.host ? ` · ${data.host}` : '';
modelTag.textContent = data.fallback_used
? `⚡ fallback → ${label}${hostSuffix}`
: `${label}${hostSuffix}`;
thinkingDiv.appendChild(modelTag);
} else if (data.type === 'error') {
throw new Error(data.message);
}
}
}
} catch (err) {
if (err.name === 'AbortError') {
thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
// Show error + retry button
thinkingDiv.className = 'message error';
thinkingDiv.innerHTML = '';
const errSpan = document.createElement('span');
errSpan.textContent = `Error: ${err.message}`;
thinkingDiv.appendChild(errSpan);
const retryBtn = document.createElement('button');
retryBtn.className = 'retry-btn';
retryBtn.textContent = '↺ Retry';
retryBtn.addEventListener('click', async () => {
// Roll back the failed user push, re-push, and try again
if (currentHistory.at(-1)?.role === 'user') currentHistory.pop();
currentHistory.push({ role: 'user', content: payload.message });
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = '✨ thinking…';
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
await _doSend(payload, thinkingDiv);
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
}
}
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
activeController = new AbortController();
const userHistIdx = currentHistory.length;
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
attachHistoryControls(userMsgDiv, userHistIdx);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
const payload = {
message: text,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
off_record: current_mode === 'otr',
model: primaryBackend,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
};
await _doSend(payload, thinkingDiv);
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
}
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
activeController = new AbortController();
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
try {
const res = await fetch('/orchestrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task: text,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
}),
signal: activeController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { job_id } = await res.json();
// Poll until complete or stopped
let job;
while (true) {
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
await new Promise(r => setTimeout(r, 2000));
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
const pollRes = await fetch(`/orchestrate/${job_id}`, {
signal: activeController.signal,
});
if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`);
job = await pollRes.json();
const n = job.tool_calls?.length || 0;
if (job.status === 'queued' || job.status === 'running') {
thinkingDiv.textContent = n
? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)`
: '⚡ working…';
continue;
}
break;
}
if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed');
// Update session so this turn is part of the resumable history
if (job.session_id) {
sessionId = job.session_id;
sessionEl.textContent = `session: ${sessionId}`;
persist_session();
}
const userHistIdx = currentHistory.length - 1; // pushed before fetch
attachHistoryControls(userMsgDiv, userHistIdx);
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', job.response || '(no response)');
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: job.response || '' });
attachHistoryControls(thinkingDiv, assistHistIdx);
renderToolCalls(job.tool_calls, thinkingDiv.parentElement);
} catch (err) {
if (err.name === 'AbortError') {
thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
thinkingDiv.className = 'message error';
thinkingDiv.textContent = `Error: ${err.message}`;
}
}
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
}
function dispatchSend() {
if (current_mode === 'note') addNote();
else if (current_mode === 'agent') sendOrchestrate();
else sendMessage();
}
sendBtn.addEventListener('click', dispatchSend);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
if (shouldSend) {
e.preventDefault();
dispatchSend();
}
}
});
inputEl.addEventListener('input', syncHeight);
// ── File editor ──────────────────────────────────────────────
const fileModal = document.getElementById('file-modal');
const fileSidebar = document.getElementById('file-sidebar');
const fileEditor = document.getElementById('file-editor');
const filePreview = document.getElementById('file-preview');
const fileRawBtn = document.getElementById('file-raw-btn');
const filePreviewBtn = document.getElementById('file-preview-btn');
const fileSaveBtn = document.getElementById('file-save-btn');
const fileCloseBtn = document.getElementById('file-close-btn');
const filesBtn = document.getElementById('files-btn');
let fileMode = 'preview'; // 'edit' or 'preview'
let activeFileName = null;
// File groups — controls sidebar order and section labels
const FILE_GROUPS = [
{ label: 'Identity', files: ['IDENTITY.md', 'SOUL.md', 'PROTOCOLS.md', 'CONTEXT_TIERS.md'] },
{ label: 'Memory', files: ['MEMORY_LONG.md', 'MEMORY_MID.md', 'MEMORY_SHORT.md'] },
{ label: 'Profile', files: ['USER.md', 'HELP.md'] },
];
function fmtSize(bytes) {
if (!bytes) return 'empty';
if (bytes < 1024) return bytes + ' B';
return (bytes / 1024).toFixed(1) + ' KB';
}
function fmtModified(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
if (d.toDateString() === now.toDateString()) return 'today';
const diff = (now - d) / 86400000;
if (diff < 2) return 'yesterday';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function renderFileSidebar(files) {
const byName = Object.fromEntries(files.map(f => [f.name, f]));
fileSidebar.innerHTML = '';
for (const group of FILE_GROUPS) {
const groupEl = document.createElement('div');
groupEl.className = 'file-group';
const header = document.createElement('div');
header.className = 'fg-header';
header.textContent = group.label;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const items = document.createElement('div');
items.className = 'fg-items';
for (const fname of group.files) {
const f = byName[fname];
if (!f) continue;
const item = document.createElement('div');
item.className = 'file-item' + (f.exists ? '' : ' missing');
item.dataset.name = fname;
if (fname === activeFileName) item.classList.add('active');
const nameEl = document.createElement('div');
nameEl.className = 'fi-name';
nameEl.textContent = fname;
item.appendChild(nameEl);
const metaEl = document.createElement('div');
metaEl.className = 'fi-meta';
metaEl.innerHTML = `<span>${fmtSize(f.size)}</span>`
+ (f.modified ? `<span>${fmtModified(f.modified)}</span>` : '');
item.appendChild(metaEl);
item.addEventListener('click', () => loadFile(fname));
items.appendChild(item);
}
groupEl.appendChild(items);
fileSidebar.appendChild(groupEl);
}
}
function setActiveFile(name) {
activeFileName = name;
fileSidebar.querySelectorAll('.file-item').forEach(el => {
el.classList.toggle('active', el.dataset.name === name);
});
document.getElementById('file-modal-title').textContent = name;
}
function setFileMode(mode) {
fileMode = mode;
if (mode === 'edit') {
fileEditor.classList.remove('hidden');
filePreview.classList.remove('active');
fileRawBtn.classList.add('active');
filePreviewBtn.classList.remove('active');
} else {
fileEditor.classList.add('hidden');
filePreview.classList.add('active');
fileRawBtn.classList.remove('active');
filePreviewBtn.classList.add('active');
if (typeof marked !== 'undefined') {
filePreview.innerHTML = marked.parse(fileEditor.value);
filePreview.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
}
}
}
async function loadFile(name) {
setActiveFile(name);
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; }
const data = await res.json();
fileEditor.value = data.content;
setFileMode(fileMode);
}
async function openFileModal() {
const res = await fetch(`/files?${_fileParams}`);
const data = await res.json();
renderFileSidebar(data.files);
fileModal.classList.add('open');
// Load first existing file
const first = data.files.find(f => f.exists) || data.files[0];
if (first) await loadFile(first.name);
}
filesBtn.addEventListener('click', () => {
settings_dd_el.classList.remove('open');
openFileModal();
});
fileRawBtn.addEventListener('click', () => setFileMode('edit'));
filePreviewBtn.addEventListener('click', () => setFileMode('preview'));
fileSaveBtn.addEventListener('click', async () => {
if (!activeFileName) return;
const res = await fetch(`/files/${encodeURIComponent(activeFileName)}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fileEditor.value }),
});
if (res.ok) {
showToast('File saved', 'success');
// Refresh sidebar to update size/modified
const listRes = await fetch(`/files?${_fileParams}`);
const listData = await listRes.json();
renderFileSidebar(listData.files);
} else {
showToast('Save failed', 'error');
}
});
fileCloseBtn.addEventListener('click', () => fileModal.classList.remove('open'));
fileModal.addEventListener('click', (e) => {
if (e.target === fileModal) fileModal.classList.remove('open');
});
// ── Session search ────────────────────────────────────────────
const sessionSearchInput = document.getElementById('session-search-input');
const sessionSearchBtn = document.getElementById('session-search-btn');
const sessionSearchResults = document.getElementById('session-search-results');
function _showFileView() {
fileEditor.style.display = '';
filePreview.style.display = '';
sessionSearchResults.style.display = 'none';
}
function _showSearchResults(html) {
fileEditor.style.display = 'none';
filePreview.style.display = 'none';
sessionSearchResults.style.display = '';
sessionSearchResults.innerHTML = html;
}
async function runSessionSearch() {
const q = sessionSearchInput.value.trim();
if (q.length < 2) return;
sessionSearchBtn.disabled = true;
sessionSearchBtn.textContent = '…';
try {
const res = await fetch(`/sessions/search?q=${encodeURIComponent(q)}&${_fileParams}&limit=30`);
const data = await res.json();
if (!res.ok) { _showSearchResults(`<p class="sr-error">Error: ${data.detail || res.status}</p>`); return; }
if (!data.matches.length) {
_showSearchResults(`<p class="sr-empty">No results for "<strong>${_esc(q)}</strong>" in ${data.total_files_searched} session file(s).</p>`);
return;
}
let html = `<div class="sr-header">${data.matches.length} result(s) for "<strong>${_esc(q)}</strong>" across ${data.total_files_searched} session(s)</div>`;
let lastDate = null;
for (const m of data.matches) {
if (m.date !== lastDate) {
html += `<div class="sr-date">${m.date}</div>`;
lastDate = m.date;
}
const hi = m.excerpt.replace(new RegExp(_esc(q), 'gi'), s => `<mark>${_esc(s)}</mark>`);
html += `<div class="sr-excerpt">${hi}</div>`;
}
_showSearchResults(html);
} catch (e) {
_showSearchResults(`<p class="sr-error">Search failed: ${e.message}</p>`);
} finally {
sessionSearchBtn.disabled = false;
sessionSearchBtn.textContent = 'Go';
}
}
sessionSearchBtn.addEventListener('click', runSessionSearch);
sessionSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') runSessionSearch();
});
// When a file is clicked, switch back from search results to editor
fileSidebar.addEventListener('click', () => {
if (sessionSearchResults.style.display !== 'none') _showFileView();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
}
// Ctrl+S to save when file modal is open
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
e.preventDefault();
fileSaveBtn.click();
}
});
// ── Real-time Talk updates (SSE) ─────────────────────────────
const evtSource = new EventSource('/events');
// Close cleanly on navigation so the browser doesn't log "connection interrupted"
window.addEventListener('beforeunload', () => evtSource.close());
evtSource.onerror = () => {
// EventSource auto-reconnects — nothing to do; suppress console noise
};
evtSource.onmessage = (e) => {
let data;
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') return;
if (data.type === 'claude_auth_expired') {
let banner = document.getElementById('claude-auth-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'claude-auth-banner';
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;';
banner.innerHTML = '<span>⚠️ Claude authentication expired — run <code style="background:#991b1b;padding:0.1rem 0.3rem;border-radius:3px;">claude</code> in your terminal to re-authenticate, then reload.</span>'
+ '<button onclick="this.parentElement.remove()" style="background:none;border:none;color:#fef2f2;font-size:1.1rem;cursor:pointer;padding:0 0.3rem;">✕</button>';
document.body.prepend(banner);
}
return;
}
if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) {
// Active session — append live
if (data.type === 'nct_message') {
// Clear any stale thinking div before new user msg
if (talkThinkingDiv) { talkThinkingDiv.remove(); talkThinkingDiv = null; }
addMessage('user', data.content);
talkThinkingDiv = addMessage('assistant thinking', '✨ thinking…');
} else {
if (talkThinkingDiv) {
talkThinkingDiv.className = 'message assistant';
setMessageText(talkThinkingDiv, 'assistant', data.content);
talkThinkingDiv = null;
} else {
addMessage('assistant', data.content);
}
scrollToBottom();
}
} else {
// Different session — light badge on Sessions button
if (data.type === 'nct_message') {
sessionsBtn.classList.add('talk-badge');
}
}
};
// ── Theme toggle ──────────────────────────────────────────────
const themeBtn = document.getElementById('theme-btn');
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
themeBtn.textContent = theme === 'dark' ? '☀' : '☾';
themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
}
{
const saved = localStorage.getItem('theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved || (sysDark ? 'dark' : 'light'));
}
themeBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
});
// ── Font size cycle ───────────────────────────────────────────
const fontSizeBtn = document.getElementById('font-size-btn');
const fontSizes = ['normal', 'large', 'small'];
const fontSizePx = { normal: '21px', large: '25px', small: '17px' };
const fontSizeLbl = { normal: 'Aa', large: 'A+', small: 'A' };
function applyFontSize(size) {
document.documentElement.style.fontSize = fontSizePx[size];
fontSizeBtn.textContent = fontSizeLbl[size];
fontSizeBtn.title = `Font: ${size} — click to cycle`;
}
{
const saved = localStorage.getItem('font-size') || 'normal';
applyFontSize(saved);
}
fontSizeBtn.addEventListener('click', () => {
const current = localStorage.getItem('font-size') || 'normal';
const next = fontSizes[(fontSizes.indexOf(current) + 1) % fontSizes.length];
localStorage.setItem('font-size', next);
applyFontSize(next);
});
// ── Context panel — tier + memory toggles + distill ───────────
const ctxOpenBtn = document.getElementById('ctx-open-btn');
const ctxPanel = document.getElementById('ctx-panel');
const distillStatus = document.getElementById('ctx-distill-status');
let currentTier = parseInt(localStorage.getItem('ctx-tier') || '2');
let memLong = localStorage.getItem('mem-long') !== 'false';
let memMid = localStorage.getItem('mem-mid') !== 'false';
let memShort = localStorage.getItem('mem-short') !== 'false';
function updateTierUI() {
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.tier) === currentTier);
});
ctxOpenBtn.querySelector('.tier-badge').textContent = currentTier;
}
function updateMemUI() {
document.getElementById('mem-long-btn').classList.toggle('mem-on', memLong);
document.getElementById('mem-mid-btn').classList.toggle('mem-on', memMid);
document.getElementById('mem-short-btn').classList.toggle('mem-on', memShort);
}
function formatNextRun(iso) {
if (!iso) return 'n/a';
const dt = new Date(iso);
const now = new Date();
const diffMs = dt - now;
const diffDays = Math.floor(diffMs / 86400000);
const time = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 0) return `today ${time}`;
if (diffDays === 1) return `tomorrow ${time}`;
return dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
async function loadSchedule() {
const schedEl = document.getElementById('ctx-schedule');
try {
const res = await fetch('/distill/status');
const d = await res.json();
if (!d.enabled || !d.jobs.length) {
schedEl.textContent = 'auto-distill disabled';
return;
}
schedEl.innerHTML = d.jobs
.map(j => `${j.id.replace('distill_', '').padEnd(6)}${formatNextRun(j.next_run)}`)
.join('<br>');
} catch {
schedEl.textContent = '';
}
}
ctxOpenBtn.addEventListener('click', (e) => {
e.stopPropagation();
ctxPanel.classList.toggle('open');
if (ctxPanel.classList.contains('open')) loadSchedule();
});
document.addEventListener('click', (e) => {
if (!ctxPanel.contains(e.target) && e.target !== ctxOpenBtn) {
ctxPanel.classList.remove('open');
}
});
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.addEventListener('click', () => {
currentTier = parseInt(btn.dataset.tier);
localStorage.setItem('ctx-tier', currentTier);
updateTierUI();
});
});
document.getElementById('mem-long-btn').addEventListener('click', () => {
memLong = !memLong; localStorage.setItem('mem-long', memLong); updateMemUI();
});
document.getElementById('mem-mid-btn').addEventListener('click', () => {
memMid = !memMid; localStorage.setItem('mem-mid', memMid); updateMemUI();
});
document.getElementById('mem-short-btn').addEventListener('click', () => {
memShort = !memShort; localStorage.setItem('mem-short', memShort); updateMemUI();
});
function showDistillStatus(msg, isErr) {
distillStatus.textContent = msg;
distillStatus.classList.toggle('err', !!isErr);
distillStatus.classList.add('show');
setTimeout(() => distillStatus.classList.remove('show'), 5000);
}
async function runDistill(endpoint) {
showDistillStatus('distilling…', false);
try {
const res = await fetch(`/distill/${endpoint}`, { method: 'POST' });
const d = await res.json();
if (!res.ok || d.ok === false) {
const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;
showDistillStatus(`${err}`, true);
} else {
showDistillStatus(`${endpoint} done`, false);
}
} catch (err) {
showDistillStatus(`${err.message}`, true);
}
}
document.getElementById('distill-short-btn').addEventListener('click', () => runDistill('short'));
document.getElementById('distill-mid-btn').addEventListener('click', () => runDistill('mid'));
document.getElementById('distill-long-btn').addEventListener('click', () => runDistill('long'));
document.getElementById('distill-all-btn').addEventListener('click', () => runDistill('all'));
updateTierUI();
updateMemUI();
update_mode_ui();
// ── Init ─────────────────────────────────────────────────────
updateEnterToggleUI();
syncHeight();
addMessage('system', 'Session started');
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');
const authBannerHint = document.getElementById('auth-banner-hint');
const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() {
try {
const res = await fetch('/auth/status');
if (!res.ok) return;
const d = await res.json();
const warnings = [];
const fixes = [];
let anyExpired = false;
if (d.claude?.warning) {
if (d.claude.expired) {
warnings.push('✕ Claude CLI token has expired');
anyExpired = true;
} else {
warnings.push(`⚠ Claude CLI token expires in ${d.claude.access_token_hours_remaining}h`);
}
fixes.push('<code>claude</code>');
}
if (d.gemini?.warning) {
warnings.push('⚠ Gemini CLI not authenticated');
fixes.push('<code>gemini</code>');
}
if (!warnings.length) {
authBanner.classList.remove('show');
return;
}
authBannerMsg.innerHTML = warnings.join('<br>');
authBannerHint.innerHTML =
`To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')}`
+ 'follow the login prompt, then restart Cortex.';
authBanner.classList.toggle('expired', anyExpired);
authBanner.classList.add('show');
} catch { /* silently ignore — don't break the UI */ }
}
authBannerClose.addEventListener('click', () => authBanner.classList.remove('show'));
checkAuthStatus();
// Re-check every 30 minutes
setInterval(checkAuthStatus, 30 * 60 * 1000);
// ── Initial render ────────────────────────────────────────────
// Process all static Lucide SVGs in the header + stop button,
// and seed the mode UI (which also calls render_icons internally).
update_mode_ui();
render_icons();
// ── Auto-restore last session ─────────────────────────────────
// Silently resume if within the inactivity TTL; clears stored ID on error.
{
const stored = get_stored_session();
if (stored) resumeSession(stored, true).catch(clear_stored_session);
}