Files
Cortex-Inara/cortex/static/app.js
Scott Idem a9bbb668b5 feat: session auth + per-user/persona UI at /{user}/{persona}
Replaces nginx basic auth with a proper per-user session system:

- auth_utils.py: bcrypt password hashing, JWT cookie creation/decode
- auth_middleware.py: validates JWT cookie on all routes except /login,
  /health, /static/, and webhook endpoints (/channels/, /webhook/)
- routers/ui.py: GET /login, POST /login, POST /logout,
  GET /{username}/{persona} — serves index.html with CORTEX_CONFIG injected
- static/login.html: minimal login form (dark theme, matches UI)
- main.py: registers SessionAuthMiddleware + ui.router
- config.py: jwt_secret, jwt_expire_days settings
- manage_passwords.py: CLI tool to set/check/list user passwords
- app.js: reads window.CORTEX_CONFIG (user + persona), sends both on
  every /chat and /orchestrate request; persona name shown in header;
  logout button (⏏) added to header
- requirements.txt: bcrypt, PyJWT, python-multipart
- .env.default: JWT_SECRET, JWT_EXPIRE_DAYS documented
- tests: client fixture injects JWT cookie; security test assertions
  updated for URL-normalized path traversal paths (still secure, codes differ)

All 80 tests pass.

Setup for a new user:
  python manage_passwords.py set scott
  python manage_passwords.py set holly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:54:12 -04:00

1181 lines
50 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 heightRow = document.getElementById('height-row');
const heightSel = document.getElementById('height-sel');
const enterToggle = document.getElementById('enter-toggle');
const noteTypeBtnEl = document.getElementById('note-type-btn');
const noteBtnEl = document.getElementById('note-btn');
const agentModeBtnEl = document.getElementById('agent-mode-btn');
const stopBtn = document.getElementById('stop');
// 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';
let sessionId = null;
let primaryBackend = 'claude';
let activeController = null;
let currentHistory = []; // mirrors backend session [{role, content}, ...]
let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates
// ── 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';
// Show semi-hidden controls when content exceeds ~3 lines or a larger max is set
const showExtras = sh > 80 || maxHeight > 120;
heightRow.style.display = showExtras ? 'flex' : 'none';
enterToggle.style.display = showExtras ? 'block' : 'none';
}
heightSel.value = String(maxHeight);
heightSel.addEventListener('change', () => {
maxHeight = parseInt(heightSel.value);
localStorage.setItem('maxHeight', maxHeight);
syncHeight();
});
// ── Agent mode ───────────────────────────────────────────────
let agentMode = localStorage.getItem('agentMode') === 'true';
function updateAgentModeUI() {
agentModeBtnEl.classList.toggle('active', agentMode);
updateInputPlaceholder();
if (agentMode) sendBtn.textContent = 'Run';
else if (!noteMode) sendBtn.textContent = 'Send';
}
agentModeBtnEl.addEventListener('click', () => {
agentMode = !agentMode;
localStorage.setItem('agentMode', agentMode);
updateAgentModeUI();
inputEl.focus();
});
// ── Note mode ────────────────────────────────────────────────
let noteMode = false;
let notePublic = false;
function updateInputMode() {
if (noteMode) {
noteBtnEl.classList.add('active');
noteTypeBtnEl.style.display = 'block';
sendBtn.textContent = 'Add Note';
inputEl.classList.add('note-mode');
if (notePublic) {
inputEl.classList.add('public');
noteBtnEl.classList.add('public');
noteTypeBtnEl.textContent = 'public';
noteTypeBtnEl.classList.add('public');
} else {
inputEl.classList.remove('public');
noteBtnEl.classList.remove('public');
noteTypeBtnEl.textContent = 'private';
noteTypeBtnEl.classList.remove('public');
}
} else {
noteBtnEl.classList.remove('active', 'public');
noteTypeBtnEl.style.display = 'none';
sendBtn.textContent = agentMode ? 'Run' : 'Send';
inputEl.classList.remove('note-mode', 'public');
}
updateInputPlaceholder();
}
function updateInputPlaceholder() {
if (noteMode) {
inputEl.placeholder = notePublic
? 'Public note — LLM sees this next turn…'
: 'Private note — only you see this…';
} else if (agentMode) {
inputEl.placeholder = ctrlEnterMode
? 'Task for Inara… (Gemini tool loop — Ctrl+Enter to run)'
: 'Task for Inara… (Gemini tool loop)';
} else {
inputEl.placeholder = ctrlEnterMode
? 'Message Inara… (Ctrl+Enter to send)'
: 'Message Inara…';
}
}
noteBtnEl.addEventListener('click', () => {
noteMode = !noteMode;
updateInputMode();
inputEl.focus();
});
noteTypeBtnEl.addEventListener('click', () => {
notePublic = !notePublic;
updateInputMode();
});
// ── Persona name in header ───────────────────────────────────
const personaNameEl = document.getElementById('persona-name');
if (personaNameEl && CORTEX_PERSONA) {
// Capitalize first letter
personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
}
// ── Backend toggle ───────────────────────────────────────────
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary));
function setBackendUI(backend) {
primaryBackend = backend;
backendToggle.textContent = backend;
backendToggle.className = 'ctx-btn' + (backend === 'gemini' ? ' mem-on' : '');
}
backendToggle.addEventListener('click', async () => {
const next = primaryBackend === 'claude' ? 'gemini' : 'claude';
const res = await fetch('/backend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ primary: next }),
});
const d = await res.json();
setBackendUI(d.primary);
addMessage('system', `Backend: ${d.primary} (fallback: ${d.fallback})`);
});
// ── Sessions panel ───────────────────────────────────────────
sessionsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (sessionsPanel.classList.contains('open')) {
sessionsPanel.classList.remove('open');
return;
}
const res = await fetch('/sessions');
const data = await res.json();
renderPanel(data.sessions);
sessionsPanel.classList.add('open');
});
document.addEventListener('click', (e) => {
if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) {
sessionsPanel.classList.remove('open');
}
});
function renderPanel(sessions) {
sessionsPanel.innerHTML = '';
const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => {
sessionId = null;
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
addMessage('system', 'New session');
sessionsPanel.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 item = makeItem(
s.session_id === sessionId ? 'active' : '',
s.session_id,
`${s.message_count} msgs · ${timeAgo(s.updated)}`
);
item.addEventListener('click', () => resumeSession(s.session_id));
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}`, { method: 'DELETE' });
if (sessionId === s.session_id) {
sessionId = null;
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
addMessage('system', 'Session deleted');
}
const res = await fetch('/sessions');
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) {
talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
const res = await fetch(`/history/${id}`);
const data = await res.json();
messagesEl.innerHTML = '';
sessionId = id;
sessionEl.textContent = `session: ${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);
}
addMessage('system', `Resumed session ${id}`);
scrollToBottom();
sessionsPanel.classList.remove('open');
inputEl.focus();
}
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);
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.textContent = text;
}
// 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.textContent = 'edit';
editBtn.addEventListener('click', () => {
startEdit(msgDiv);
});
const delBtn = document.createElement('button');
delBtn.className = 'msg-act-btn del';
delBtn.textContent = 'del';
delBtn.addEventListener('click', () => {
deleteMsg(wrapper);
});
actionsDiv.appendChild(editBtn);
actionsDiv.appendChild(delBtn);
}
// 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.textContent = 'Save';
saveBtn.className = 'edit-save-btn';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'edit-cancel-btn';
const btnRow = document.createElement('div');
btnRow.className = 'edit-btns';
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
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}`, {
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);
div.querySelectorAll('a').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
div.appendChild(makeCopyBtn(div));
} else {
div.textContent = text;
}
}
function makeCopyBtn(div) {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'copy';
btn.addEventListener('click', (e) => {
e.stopPropagation();
const text = div.dataset.raw || '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
btn.textContent = '✓';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = 'copy';
btn.classList.remove('copied');
}, 1500);
});
return btn;
}
async function addNote() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
syncHeight();
if (!notePublic) {
// 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) {
addMessage('system', `Note save failed: ${err.message}`);
}
}
stopBtn.addEventListener('click', () => {
if (activeController) activeController.abort();
});
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'block';
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…');
try {
const res = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 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 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}`;
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
if (data.fallback_used) {
addMessage('system',
`${primaryBackend} unavailable — answered by ${data.backend}`);
}
} else if (data.type === 'error') {
throw new Error(data.message);
}
}
}
} 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();
}
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'block';
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}`;
}
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);
const n = job.tool_calls?.length || 0;
if (n) {
const names = job.tool_calls.map(t => t.name).join(', ');
addMessage('system', `${n} tool call${n !== 1 ? 's' : ''}: ${names}`);
}
} 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 (noteMode) addNote();
else if (agentMode) 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 fileSelect = document.getElementById('file-select');
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 fileSavedMsg = document.getElementById('file-saved-msg');
const fileCloseBtn = document.getElementById('file-close-btn');
const filesBtn = document.getElementById('files-btn');
let fileMode = 'preview'; // 'edit' or 'preview'
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) {
const res = await fetch(`/files/${encodeURIComponent(name)}`);
if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; }
const data = await res.json();
fileEditor.value = data.content;
document.getElementById('file-modal-title').textContent = name;
setFileMode(fileMode);
}
async function openFileModal() {
// Populate the file list
const res = await fetch('/files');
const data = await res.json();
fileSelect.innerHTML = '';
for (const f of data.files) {
const opt = document.createElement('option');
opt.value = f.name;
opt.textContent = f.name + (f.exists ? '' : ' (missing)');
fileSelect.appendChild(opt);
}
fileModal.classList.add('open');
await loadFile(fileSelect.value);
}
filesBtn.addEventListener('click', openFileModal);
fileSelect.addEventListener('change', () => loadFile(fileSelect.value));
fileRawBtn.addEventListener('click', () => setFileMode('edit'));
filePreviewBtn.addEventListener('click', () => setFileMode('preview'));
fileSaveBtn.addEventListener('click', async () => {
const name = fileSelect.value;
const res = await fetch(`/files/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fileEditor.value }),
});
if (res.ok) {
fileSavedMsg.classList.add('show');
setTimeout(() => fileSavedMsg.classList.remove('show'), 2000);
}
});
fileCloseBtn.addEventListener('click', () => fileModal.classList.remove('open'));
fileModal.addEventListener('click', (e) => {
if (e.target === fileModal) fileModal.classList.remove('open');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
if (document.getElementById('help-modal')?.classList.contains('open'))
document.getElementById('help-modal').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');
evtSource.onmessage = (e) => {
let data;
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') 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: '16px', large: '18px', small: '14px' };
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();
updateAgentModeUI();
// ── Init ─────────────────────────────────────────────────────
updateEnterToggleUI();
syncHeight();
addMessage('system', 'Session started');
// ── Help modal ────────────────────────────────────────────────
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const helpBody = document.getElementById('help-modal-body');
const helpClose = document.getElementById('help-close-btn');
// Sections open by default — everything after "Notes" starts collapsed
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
function makeCollapsible(container) {
const h2s = Array.from(container.querySelectorAll('h2'));
for (const h2 of h2s) {
const title = h2.textContent.trim();
const details = document.createElement('details');
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
const summary = document.createElement('summary');
summary.textContent = title;
details.appendChild(summary);
// Collect all following siblings until the next h2
const siblings = [];
let node = h2.nextSibling;
while (node && node.nodeName !== 'H2') {
siblings.push(node);
node = node.nextSibling;
}
for (const sib of siblings) details.appendChild(sib);
h2.parentNode.replaceChild(details, h2);
}
}
async function openHelp() {
helpBody.textContent = 'Loading…';
helpModal.classList.add('open');
try {
const res = await fetch('/files/HELP.md');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
helpBody.innerHTML = marked.parse(data.content);
helpBody.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
makeCollapsible(helpBody);
} catch (err) {
helpBody.textContent = `Failed to load help: ${err.message}`;
}
}
helpBtn.addEventListener('click', openHelp);
helpClose.addEventListener('click', () => helpModal.classList.remove('open'));
helpModal.addEventListener('click', (e) => {
if (e.target === helpModal) helpModal.classList.remove('open');
});
// ── 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.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);