Files
Cortex-Inara/cortex/static/app.js
Scott Idem 96b3c796c5 feat: file attachment support in chat (images + text/code files)
Text files (.md, .py, .js, .json, etc.): read client-side and injected
into the message body as a fenced code block — works with all backends
with zero model capability requirements.

Images (PNG/JPG/WebP/GIF, max 5 MB): encoded as base64 data URL on the
client and sent as a separate attachment field. Backend formats them as
OpenAI multimodal content (text + image_url) for local_openai backends.
Claude CLI and Gemini CLI see the text message with a "📎 filename.png"
note; image data is never written to session history.

- index.html: 📎 button + hidden file input in mode-select row;
  attachment-row preview area with thumbnail (images) or filename chip
- app.js: _resolveAttachment(), file reader, clearAttachment();
  sendMessage/sendOrchestrate updated to allow no-text sends when a
  file is pending; attachment spread into chat payload for images
- chat.py: Attachment model; attachment field on ChatRequest;
  llm_attachment extracted in _stream_chat and passed to complete()
- llm_client.py: attachment param through complete()/_dispatch()/_local();
  _local() builds multimodal content array for vision calls
- style.css: #attach-btn, #attachment-row, #attachment-preview, thumb

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:46:50 -04:00

2366 lines
106 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 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 tools_toggle_el = document.getElementById('tools-toggle');
const settings_btn_el = document.getElementById('settings-btn');
const settings_dd_el = document.getElementById('settings-dropdown');
const sessionsBackdrop = document.getElementById('sessions-backdrop');
// ── Utilities ─────────────────────────────────────────────────
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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');
const cp = document.getElementById('ctx-panel');
if (cp) cp.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 ──────────────────────────────────────────
const HEIGHT_SIZES = [120, 240, 480];
const HEIGHT_LABELS = ['S', 'M', 'L'];
const HEIGHT_TITLES = [
'Input size: Compact — click to cycle',
'Input size: Medium — click to cycle',
'Input size: Large — click to cycle',
];
let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120');
const heightCycleBtn = document.getElementById('height-cycle-btn');
function syncHeight() {
inputEl.style.transition = '';
inputEl.style.height = 'auto';
inputEl.style.maxHeight = maxHeight + 'px';
const sh = inputEl.scrollHeight;
// Minimum height is 1/3 of maxHeight so each setting is visually distinct
const minH = Math.round(maxHeight / 3);
inputEl.style.height = Math.max(Math.min(sh, maxHeight), minH) + 'px';
}
const modeSelectEl = document.getElementById('mode-select');
function updateHeightUI() {
if (!heightCycleBtn) return;
const idx = HEIGHT_SIZES.indexOf(maxHeight);
const i = idx >= 0 ? idx : 0;
heightCycleBtn.textContent = HEIGHT_LABELS[i];
heightCycleBtn.title = HEIGHT_TITLES[i];
// Drive row/column layout via data attribute
if (modeSelectEl) modeSelectEl.dataset.size = HEIGHT_LABELS[i].toLowerCase();
}
if (heightCycleBtn) {
heightCycleBtn.addEventListener('click', () => {
const idx = HEIGHT_SIZES.indexOf(maxHeight);
const nextIdx = (idx + 1) % HEIGHT_SIZES.length;
maxHeight = HEIGHT_SIZES[nextIdx];
localStorage.setItem('maxHeight', maxHeight);
updateHeightUI();
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' },
};
const send_defs = {
chat: { icon: 'arrow-up', label: 'Send' },
note: { icon: 'pencil', label: 'Note' },
otr: { icon: 'arrow-up', label: 'Send' },
};
let current_mode = localStorage.getItem('current_mode') || 'chat';
if (!(current_mode in MODES)) current_mode = 'chat'; // migrate stored 'agent'
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"]');
mode_mru = mode_mru.filter(m => m in MODES); // strip stale 'agent' entries
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] || MODES.chat;
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');
// Send button label + icon (tools active → "Run", otherwise per-mode)
const effectiveSd = toolsEnabled && current_mode !== 'note'
? { icon: 'zap', label: 'Run' }
: sd;
sendBtn.innerHTML = icon_html(effectiveSd.icon) + ' ' + effectiveSd.label;
updateSendBtnTitle();
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 === 'otr') {
inputEl.placeholder = toolsEnabled
? `Task for ${personaLabel}… ⚡ tools + off the record`
: 'Off the record — not logged or distilled…';
} else if (toolsEnabled) {
inputEl.placeholder = ctrlEnterMode
? `Task for ${personaLabel}… ⚡ tools (Ctrl+Enter to run)`
: `Task for ${personaLabel}… ⚡ tools`;
} 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();
});
// ── Tools toggle ─────────────────────────────────────────────
// When on: submit goes to POST /orchestrate (orchestrator tool loop → active model responds).
// When off: submit goes to POST /chat (direct to active model, no tools).
let toolsEnabled = localStorage.getItem('tools-enabled') === 'true';
let _runStart = 0;
let _runTimer = null;
function updateToolsToggleUI() {
tools_toggle_el.classList.toggle('local-on', toolsEnabled);
tools_toggle_el.title = toolsEnabled
? '⚡ Tools enabled — click to disable'
: 'Tools disabled — click to enable';
update_mode_ui();
}
tools_toggle_el.addEventListener('click', (e) => {
e.stopPropagation();
toolsEnabled = !toolsEnabled;
localStorage.setItem('tools-enabled', toolsEnabled);
updateToolsToggleUI();
});
function updateSendBtnTitle() {
const entry = activeChatModel();
const rmodel = entry?.label || '(server default)';
const mode = current_mode === 'otr' ? 'Off The Record'
: current_mode === 'note' ? 'Note'
: 'Chat';
const useOrch = toolsEnabled && current_mode !== 'note';
let lines;
if (useOrch) {
const omodel = orchestratorModel || '(server default)';
lines = [
`Model: ${rmodel}`,
`Orchestrator: ${omodel} (tool loop)`,
`Mode: ${mode}`,
];
} else {
lines = [
`Model: ${rmodel}`,
`Mode: ${mode}`,
`Engine: Direct (no tool loop)`,
];
}
sendBtn.title = lines.join('\n');
}
function startRunTimer() {
_runStart = Date.now();
function tick() {
const secs = Math.floor((Date.now() - _runStart) / 1000);
const entry = activeChatModel();
const useOrch = toolsEnabled && current_mode !== 'note';
const model = useOrch
? (orchestratorModel || '(server default)') + ' (tool loop)'
: (entry?.label || '(server default)');
stopBtn.title = `Running: Chat · ${model}\nElapsed: ${secs}s — click to cancel`;
}
tick();
_runTimer = setInterval(tick, 1000);
}
function stopRunTimer() {
clearInterval(_runTimer);
_runTimer = null;
stopBtn.title = '';
updateSendBtnTitle();
}
function setProcessing(state) {
if (state) {
headerEmoji.classList.add('processing');
document.body.classList.add('processing');
} else {
headerEmoji.classList.remove('processing');
document.body.classList.remove('processing');
}
}
// ── 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 name = p.name || p;
const emoji = p.emoji || '✨';
const a = document.createElement('a');
a.href = `/${CORTEX_USER}/${name}`;
if (name === CORTEX_PERSONA) a.classList.add('active');
const emojiEl = document.createElement('span');
emojiEl.className = 'pd-emoji';
emojiEl.textContent = emoji;
a.appendChild(emojiEl);
const nameEl = document.createElement('span');
nameEl.textContent = name.charAt(0).toUpperCase() + name.slice(1);
a.appendChild(nameEl);
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'));
}
// ── Model toggle (Phase 3) ───────────────────────────────────
// Cycles through the chat role's configured slot models (primary → backup_1 → …).
// Shows the model label on the button; sends slot + chat_role:"chat" in requests.
// Falls back to "chat" / no slot when no models are configured.
const TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' };
const backendModelHint = document.getElementById('backend-model-hint');
let chatModels = []; // [{slot, label, type}] for chat-role slots
let availableRoles = []; // [{role, label, model_label, type}] — kept for banner check
let modelIdx = 0;
let orchestratorModel = null;
function activeChatModel() {
return chatModels.length > 0 ? chatModels[modelIdx] : null;
}
function setModelToggleUI(entry) {
if (!entry) {
backendToggle.textContent = 'chat';
backendToggle.className = 'ctx-btn';
} else {
backendToggle.textContent = entry.label;
backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || '');
}
if (backendModelHint) backendModelHint.style.display = 'none';
updateSendBtnTitle();
}
fetch('/backend').then(r => r.json()).then(d => {
chatModels = d.chat_models || [];
availableRoles = d.available_roles || [];
orchestratorModel = d.orchestrator_model || null;
modelIdx = 0;
setModelToggleUI(chatModels[0] || null);
_maybeShowNoBanner(availableRoles);
});
function _maybeShowNoBanner(roles) {
const key = 'cx_no_model_banner_dismissed';
if (roles.length > 0) { localStorage.removeItem(key); return; }
if (localStorage.getItem(key)) return;
const banner = document.createElement('div');
banner.id = 'no-model-banner';
banner.style.cssText = [
'background:#1c1a0a','border-bottom:1px solid #78350f',
'color:#fbbf24','font-size:0.82rem','padding:0.55rem 1rem',
'display:flex','align-items:center','gap:0.75rem','flex-shrink:0',
].join(';');
banner.innerHTML = `
<span style="flex:1">⚡ Using server default model — add your own for more choices and to track your usage.</span>
<a href="/setup/model" style="color:#fbbf24;font-weight:600;white-space:nowrap;">Set up OpenRouter →</a>
<button onclick="localStorage.setItem('${key}','1');document.getElementById('no-model-banner').remove();"
style="background:none;border:none;color:#78350f;cursor:pointer;font-size:1rem;line-height:1;padding:0 0.2rem;"
title="Dismiss">✕</button>
`;
const col = document.getElementById('chat-col') || document.body.firstElementChild;
col.insertBefore(banner, col.firstChild);
}
backendToggle.addEventListener('click', () => {
if (chatModels.length <= 1) return;
modelIdx = (modelIdx + 1) % chatModels.length;
const entry = chatModels[modelIdx];
setModelToggleUI(entry);
addMessage('system', `Model: ${entry.label}`);
});
// ── File attachment ──────────────────────────────────────────
const attachBtn = document.getElementById('attach-btn');
const fileInput = document.getElementById('file-input');
const attachRow = document.getElementById('attachment-row');
const attachName = document.getElementById('attachment-name');
const attachClear = document.getElementById('attachment-clear');
const attachThumb = document.getElementById('attachment-thumb');
const _IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const _TXT_EXTS = new Set(['.md','.txt','.py','.js','.ts','.jsx','.tsx','.json','.yaml','.yml','.toml','.html','.css','.sh','.csv','.xml','.rs','.go','.java','.c','.cpp','.h','.rb','.php','.swift','.kt','.sql','.env','.ini','.cfg','.log']);
const MAX_IMAGE_B = 5 * 1024 * 1024; // 5 MB
const MAX_TEXT_B = 100 * 1024; // 100 KB
let _pendingAttach = null; // {type:'image'|'text', filename, mime_type, data}
function _isTextFile(file) {
if (file.type.startsWith('text/') || file.type === 'application/json') return true;
const ext = '.' + file.name.split('.').pop().toLowerCase();
return _TXT_EXTS.has(ext);
}
function _langHint(filename) {
const ext = filename.split('.').pop().toLowerCase();
const m = {py:'python',js:'javascript',ts:'typescript',jsx:'jsx',tsx:'tsx',json:'json',yaml:'yaml',yml:'yaml',toml:'toml',html:'html',css:'css',sh:'bash',md:'markdown',rs:'rust',go:'go',java:'java',c:'c',cpp:'cpp',h:'c',rb:'ruby',php:'php',swift:'swift',kt:'kotlin',sql:'sql'};
return m[ext] || '';
}
function clearAttachment() {
_pendingAttach = null;
fileInput.value = '';
attachRow.style.display = 'none';
if (attachThumb) { attachThumb.src = ''; attachThumb.style.display = 'none'; }
}
/**
* Resolve the pending attachment into send-ready values.
* - Text files: inject file content as a fenced code block in the message.
* displayText = serverText = injected content (what the model sees).
* - Images: keep text separate; pass image as payloadAttachment for vision APIs.
* serverText includes a 📎 filename note for non-vision backends.
*/
function _resolveAttachment(inputText) {
if (!_pendingAttach) return { displayText: inputText, serverText: inputText, payloadAttachment: null };
const { type, filename, mime_type, data } = _pendingAttach;
if (type === 'text') {
const lang = _langHint(filename);
const block = `📎 ${filename}\n\`\`\`${lang}\n${data.trimEnd()}\n\`\`\``;
const serverText = inputText ? `${inputText}\n\n${block}` : block;
return { displayText: serverText, serverText, payloadAttachment: null };
}
// Image
const note = `📎 ${filename}`;
const displayText = inputText ? `${inputText}\n${note}` : note;
return { displayText, serverText: displayText, payloadAttachment: { filename, mime_type, data } };
}
attachBtn.addEventListener('click', () => fileInput.click());
attachClear.addEventListener('click', clearAttachment);
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
fileInput.value = ''; // reset so the same file can be re-selected
const isImg = _IMG_TYPES.has(file.type);
const isTxt = !isImg && _isTextFile(file);
if (!isImg && !isTxt) { showToast('Unsupported file type'); return; }
if (isImg && file.size > MAX_IMAGE_B) { showToast('Image too large (max 5 MB)'); return; }
if (isTxt && file.size > MAX_TEXT_B) { showToast('Text file too large (max 100 KB)'); return; }
const reader = new FileReader();
reader.onload = (e) => {
_pendingAttach = { type: isImg ? 'image' : 'text', filename: file.name, mime_type: file.type || 'text/plain', data: e.target.result };
attachName.textContent = file.name;
if (isImg && attachThumb) {
attachThumb.src = e.target.result;
attachThumb.style.display = 'block';
attachRow.querySelector('#attachment-icon').style.display = 'none';
} else if (attachThumb) {
attachThumb.style.display = 'none';
attachRow.querySelector('#attachment-icon').style.display = '';
}
attachRow.style.display = 'flex';
};
isImg ? reader.readAsDataURL(file) : reader.readAsText(file);
});
// ── 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 = document.createElement('div');
item.className = 'session-item' + (s.session_id === sessionId ? ' active' : '');
// ── Edit button (left) ──────────────────────────────────
const editBtn = document.createElement('button');
editBtn.className = 'session-edit-btn';
editBtn.textContent = '✎';
editBtn.title = 'Rename session';
// ── Body: name (top) + meta (below) ─────────────────────
const bodyEl = document.createElement('div');
bodyEl.className = 'session-body';
const labelEl = document.createElement('span');
labelEl.className = 'session-id';
labelEl.textContent = displayName;
const metaEl = document.createElement('span');
metaEl.className = 'session-meta';
metaEl.textContent = `${s.message_count} msgs · ${timeAgo(s.updated)}`;
bodyEl.append(labelEl, metaEl);
// ── Delete button (far right) ────────────────────────────
const delBtn = document.createElement('button');
delBtn.className = 'session-delete-btn';
delBtn.title = 'Delete session';
delBtn.textContent = '×';
item.append(editBtn, bodyEl, delBtn);
// Click anywhere on the row (not a button) → resume
item.addEventListener('click', (e) => {
if (!e.target.closest('button')) resumeSession(s.session_id);
});
// ── Edit mode ────────────────────────────────────────────
function enterEditMode(e) {
e.stopPropagation();
const input = document.createElement('input');
input.className = 'session-rename-input';
input.value = s.name || '';
input.placeholder = s.session_id;
// Swap body + delete for the input
bodyEl.hidden = true;
delBtn.hidden = true;
editBtn.textContent = '✓';
editBtn.title = 'Save name';
editBtn.className = 'session-save-btn';
editBtn.onclick = async (e) => { e.stopPropagation(); await commitRename(); };
editBtn.after(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 }),
});
if (sessionId === s.session_id)
sessionEl.textContent = `session: ${newName || s.session_id}`;
if (newName) showToast('Session renamed', 'success');
const res = await fetch(`/sessions?${_fileParams}`);
renderPanel((await res.json()).sessions);
}
function cancelEdit() {
input.remove();
bodyEl.hidden = false;
delBtn.hidden = false;
editBtn.textContent = '✎';
editBtn.title = 'Rename session';
editBtn.className = 'session-edit-btn';
editBtn.onclick = enterEditMode;
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
});
}
editBtn.onclick = enterEditMode;
// ── Delete ───────────────────────────────────────────────
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Swap row content for inline confirm
editBtn.hidden = true;
bodyEl.hidden = true;
delBtn.hidden = true;
const confirmRow = document.createElement('div');
confirmRow.className = 'session-confirm-row';
confirmRow.innerHTML =
'<span class="session-confirm-label">Delete this session?</span>';
const yesBtn = document.createElement('button');
yesBtn.className = 'session-confirm-yes';
yesBtn.textContent = 'Delete';
const noBtn = document.createElement('button');
noBtn.className = 'session-confirm-no';
noBtn.textContent = 'Cancel';
confirmRow.append(yesBtn, noBtn);
item.appendChild(confirmRow);
function cancelConfirm() {
confirmRow.remove();
editBtn.hidden = false;
bodyEl.hidden = false;
delBtn.hidden = false;
}
noBtn.addEventListener('click', (e) => { e.stopPropagation(); cancelConfirm(); });
yesBtn.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}`);
renderPanel((await res.json()).sessions);
});
});
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();
// Prefer name from API response, fall back to sessionNames map, then raw ID
const displayName = data.name || sessionNames.get(id) || id;
sessionNames.set(id, displayName);
messagesEl.innerHTML = '';
sessionId = id;
sessionEl.textContent = `session: ${displayName}`;
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);
setMessageMeta(msgDiv, {
label: (role === 'assistant') ? (msg.backend_label || msg.backend || '') : '',
host: msg.host || '',
otr: !!msg.off_record,
});
}
if (!silent) addMessage('system', `Resumed session: ${displayName}`);
scrollToBottom();
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
inputEl.focus();
persist_session();
}
// ── Message metadata (hover bar) ─────────────────────────────
function setMessageMeta(msgDiv, {label = '', host = '', fallback = false, otr = false} = {}) {
const wrapper = msgDiv.closest ? msgDiv.closest('.msg-wrapper') : msgDiv.parentElement;
if (!wrapper) return;
const actionsDiv = wrapper.querySelector('.msg-actions');
if (!actionsDiv) return;
const existing = actionsDiv.querySelector('.msg-meta');
if (existing) existing.remove();
if (!label && !otr) return;
const meta = document.createElement('span');
meta.className = 'msg-meta';
if (label) {
const modelSpan = document.createElement('span');
modelSpan.className = 'msg-meta-model' + (fallback ? ' fallback' : '');
modelSpan.textContent = (fallback ? '⚡ ' : '') + label + (host ? ' · ' + host : '');
meta.appendChild(modelSpan);
}
if (otr) {
const badge = document.createElement('span');
badge.className = 'msg-meta-badge otr';
badge.textContent = 'OTR';
meta.appendChild(badge);
}
actionsDiv.insertBefore(meta, actionsDiv.firstChild);
}
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', () => {
actionsDiv.innerHTML = '';
const yesBtn = document.createElement('button');
yesBtn.className = 'msg-act-btn del';
yesBtn.textContent = 'confirm delete';
yesBtn.addEventListener('click', () => deleteMsg(wrapper));
const noBtn = document.createElement('button');
noBtn.className = 'msg-act-btn';
noBtn.textContent = 'cancel';
noBtn.addEventListener('click', () =>
attachHistoryControls(msgDiv, parseInt(wrapper.dataset.histIdx)));
actionsDiv.append(yesBtn, noBtn);
});
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, wasNewSession = false) {
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();
// Auto-name the session from the first user message
if (wasNewSession) {
const autoName = text.slice(0, 60).trimEnd() + (text.length > 60 ? '…' : '');
fetch(`/sessions/${sessionId}?${_fileParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: autoName }),
}).then(() => {
sessionEl.textContent = `session: ${autoName}`;
sessionNames.set(sessionId, autoName);
}).catch(() => {});
}
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
setMessageMeta(thinkingDiv, {
label: data.backend_label || data.backend || '',
host: data.host || '',
fallback: !!data.fallback_used,
otr: current_mode === 'otr',
});
} 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';
setProcessing(true);
startRunTimer();
await _doSend(payload, thinkingDiv, false);
activeController = null;
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
}
}
}
async function sendMessage() {
const rawText = inputEl.value.trim();
if ((!rawText && !_pendingAttach) || activeController) return;
const wasNewSession = !sessionId;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
setProcessing(true);
startRunTimer();
activeController = new AbortController();
const isOtr = current_mode === 'otr';
const { displayText, serverText, payloadAttachment } = _resolveAttachment(rawText);
clearAttachment();
const userHistIdx = currentHistory.length;
currentHistory.push({ role: 'user', content: serverText });
const userMsgDiv = addMessage('user', displayText);
attachHistoryControls(userMsgDiv, userHistIdx);
if (isOtr) setMessageMeta(userMsgDiv, {otr: true});
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
const payload = {
message: serverText,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
off_record: isOtr,
chat_role: 'chat',
slot: activeChatModel()?.slot || null,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
...(payloadAttachment ? { attachment: payloadAttachment } : {}),
};
await _doSend(payload, thinkingDiv, wasNewSession);
activeController = null;
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
}
// Extracted so the retry button can call it without re-adding the
// user message to the DOM or currentHistory.
async function _doOrchestrate(text, thinkingDiv, userMsgDiv) {
const submitOtr = current_mode === 'otr';
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,
off_record: current_mode === 'otr',
chat_role: 'chat',
slot: activeChatModel()?.slot || null,
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;
}
if (job.status === 'awaiting_confirmation') {
const pc = job.pending_confirmation || {};
const toolNames = (pc.tools || []).map(t => t.name).join(', ');
thinkingDiv.className = 'message assistant';
thinkingDiv.innerHTML = `<div class="confirm-gate">
<p>${escapeHtml(pc.message || 'Confirm this action?')}</p>
<p class="confirm-tools">Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: <code>${escapeHtml(toolNames)}</code></p>
<div class="confirm-actions">
<button class="confirm-btn">Confirm</button>
<button class="deny-btn">Deny</button>
</div>
</div>`;
const confirmed = await new Promise(resolve => {
thinkingDiv.querySelector('.confirm-btn').onclick = () => resolve(true);
thinkingDiv.querySelector('.deny-btn').onclick = () => resolve(false);
});
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = confirmed ? '⚡ confirmed — continuing…' : '⚡ denied — finishing…';
const action = confirmed ? 'confirm' : 'deny';
const resumeRes = await fetch(`/orchestrate/${job_id}/${action}`, {
method: 'POST',
signal: activeController.signal,
});
if (!resumeRes.ok) throw new Error(`Resume failed: HTTP ${resumeRes.status}`);
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);
setMessageMeta(thinkingDiv, {
label: job.backend_label || job.backend || '',
host: job.host || '',
otr: submitOtr,
});
if (submitOtr) setMessageMeta(userMsgDiv, {otr: true});
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.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 () => {
if (currentHistory.at(-1)?.role === 'user') currentHistory.pop();
currentHistory.push({ role: 'user', content: text });
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = '⚡ working…';
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
setProcessing(true);
startRunTimer();
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
activeController = null;
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
}
}
}
async function sendOrchestrate() {
const rawText = inputEl.value.trim();
if ((!rawText && !_pendingAttach) || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
setProcessing(true);
startRunTimer();
activeController = new AbortController();
const { displayText, serverText } = _resolveAttachment(rawText);
clearAttachment();
currentHistory.push({ role: 'user', content: serverText });
const userMsgDiv = addMessage('user', displayText);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
await _doOrchestrate(serverText, thinkingDiv, userMsgDiv);
activeController = null;
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
}
function dispatchSend() {
if (current_mode === 'note') addNote();
else if (toolsEnabled) 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 fileEditorWrap = document.getElementById('file-editor-wrap');
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;
let mdEditor = null;
function initMdEditor() {
if (mdEditor) return;
mdEditor = CodeMirror(fileEditorWrap, {
mode: 'markdown',
lineWrapping: true,
lineNumbers: false,
autofocus: false,
tabSize: 2,
indentWithTabs: false,
extraKeys: { 'Ctrl-S': () => { fileSaveBtn.click(); return false; } },
});
}
// 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'] },
{ label: 'Settings', files: ['email_allowlist.json'] },
{ label: 'Agent Notes (read-only)', files: ['AGENT_NOTES.bak1.md', 'AGENT_NOTES.bak2.md', 'AGENT_NOTES.bak3.md'], collapsed: true },
];
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 _makeFileGroup(label, collapsed = false) {
const groupEl = document.createElement('div');
groupEl.className = 'file-group';
const header = document.createElement('div');
header.className = 'fg-header' + (collapsed ? ' collapsed' : '');
header.textContent = label;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const items = document.createElement('div');
items.className = 'fg-items';
groupEl.appendChild(items);
return { groupEl, items };
}
function renderFileSidebar(files, auditDates = []) {
const byName = Object.fromEntries(files.map(f => [f.name, f]));
fileSidebar.innerHTML = '';
for (const group of FILE_GROUPS) {
const { groupEl, items } = _makeFileGroup(group.label, group.collapsed || false);
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);
}
fileSidebar.appendChild(groupEl);
}
// ── Audit Log section (dynamic, date-named files) ──────────
if (auditDates.length > 0) {
const { groupEl, items } = _makeFileGroup('Audit Log', true);
for (const d of auditDates) {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.name = 'audit:' + d;
if (activeFileName === 'audit:' + d) item.classList.add('active');
const nameEl = document.createElement('div');
nameEl.className = 'fi-name';
nameEl.textContent = d + '.jsonl';
item.appendChild(nameEl);
const metaEl = document.createElement('div');
metaEl.className = 'fi-meta';
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
metaEl.innerHTML = `<span>${d === today ? 'today' : d === yesterday ? 'yesterday' : d}</span>`;
item.appendChild(metaEl);
item.addEventListener('click', () => loadAuditLog(d));
items.appendChild(item);
}
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') {
fileEditorWrap.classList.remove('hidden');
filePreview.classList.remove('active');
fileRawBtn.classList.add('active');
filePreviewBtn.classList.remove('active');
mdEditor.refresh();
mdEditor.focus();
} else {
fileEditorWrap.classList.add('hidden');
filePreview.classList.add('active');
fileRawBtn.classList.remove('active');
filePreviewBtn.classList.add('active');
if (typeof marked !== 'undefined') {
filePreview.innerHTML = marked.parse(mdEditor.getValue());
filePreview.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
}
}
}
async function loadFile(name) {
setActiveFile(name);
initMdEditor();
// Restore editor/preview buttons hidden by audit view
fileRawBtn.style.display = '';
filePreviewBtn.style.display = '';
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; }
const data = await res.json();
mdEditor.setValue(data.content);
mdEditor.clearHistory();
if (data.readonly) {
mdEditor.setOption('readOnly', 'nocursor');
fileSaveBtn.style.display = 'none';
document.getElementById('file-modal-title').textContent = name + ' (read-only)';
} else {
mdEditor.setOption('readOnly', false);
fileSaveBtn.style.display = '';
document.getElementById('file-modal-title').textContent = name;
}
setFileMode(fileMode);
}
function _auditStatusClass(status) {
if (status === 'ok') return 'at-status ok';
if (status === 'error') return 'at-status error';
if (status === 'denied') return 'at-status denied';
return 'at-status';
}
function _fmtArgs(args) {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([k, v]) => {
const s = typeof v === 'string' ? v : JSON.stringify(v);
return `${k}: ${s.length > 60 ? s.slice(0, 60) + '…' : s}`;
})
.join(' · ');
}
async function loadAuditLog(dateStr) {
setActiveFile('audit:' + dateStr);
document.getElementById('file-modal-title').textContent = dateStr + '.jsonl';
// Hide edit controls — audit logs are read-only
fileRawBtn.style.display = 'none';
filePreviewBtn.style.display = 'none';
fileSaveBtn.style.display = 'none';
fileEditorWrap.classList.add('hidden');
filePreview.classList.add('active');
filePreview.style.display = '';
filePreview.innerHTML = '<div class="audit-empty">Loading…</div>';
const res = await fetch(`/api/audit/day?date=${encodeURIComponent(dateStr)}`);
if (!res.ok) {
filePreview.innerHTML = '<div class="audit-empty">Failed to load audit log.</div>';
return;
}
const data = await res.json();
const entries = data.entries || [];
if (entries.length === 0) {
filePreview.innerHTML = '<div class="audit-empty">No entries for this date.</div>';
return;
}
const table = document.createElement('table');
table.className = 'audit-table';
table.innerHTML = `<thead><tr>
<th class="at-time">Time</th>
<th class="at-tool">Tool</th>
<th class="at-status">Status</th>
<th class="at-model">Model</th>
<th class="at-args">Args</th>
<th class="at-result">Result</th>
</tr></thead>`;
const tbody = document.createElement('tbody');
for (const e of entries) {
const time = (e.ts || '').slice(11, 19); // HH:MM:SS
const model = e.model || e.engine || '';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="at-time">${time}</td>
<td class="at-tool" title="${e.tool || ''}">${e.tool || '?'}</td>
<td class="${_auditStatusClass(e.status)}">${e.status || '?'}</td>
<td class="at-model" title="${model}">${model}</td>
<td class="at-args" title="${(_fmtArgs(e.args) || '').replace(/"/g, '&quot;')}">${_fmtArgs(e.args)}</td>
<td class="at-result" title="${(e.result_snippet || '').replace(/</g, '&lt;').replace(/"/g, '&quot;')}">${
(e.result_snippet || '').replace(/</g, '&lt;').slice(0, 80)
+ (e.result_chars > 80 ? `… <span style="color:var(--muted)">[${e.result_chars} chars]</span>` : '')
}</td>`;
tbody.appendChild(tr);
}
table.appendChild(tbody);
filePreview.innerHTML = '';
filePreview.appendChild(table);
}
async function openFileModal() {
const [filesRes, auditRes] = await Promise.all([
fetch(`/files?${_fileParams}`),
fetch('/api/audit/files'),
]);
const filesData = await filesRes.json();
const auditData = auditRes.ok ? await auditRes.json() : { dates: [] };
renderFileSidebar(filesData.files, auditData.dates);
fileModal.classList.add('open');
// Load first existing regular file
const first = filesData.files.find(f => f.exists) || filesData.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: mdEditor.getValue() }),
});
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() {
setFileMode(fileMode);
filePreview.style.display = '';
sessionSearchResults.style.display = 'none';
}
function _showSearchResults(html) {
fileEditorWrap.classList.add('hidden');
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 !== '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');
const _metaThemeColor = document.getElementById('meta-theme-color');
const _themeColors = { dark: '#1a1228', light: '#f2eef9' };
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';
if (_metaThemeColor) _metaThemeColor.content = _themeColors[theme] || _themeColors.dark;
}
{
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';
const TIER_LABELS = { 1: 'Min', 2: 'Std', 3: 'Ext', 4: 'Full' };
function updateTierUI() {
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.tier) === currentTier);
});
ctxOpenBtn.querySelector('.tier-badge').textContent = TIER_LABELS[currentTier] || 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();
const isOpen = ctxPanel.classList.contains('open');
closeAllPanels();
if (!isOpen) {
ctxPanel.classList.add('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();
});
const _distillBtns = () => document.querySelectorAll(
'#distill-short-btn, #distill-mid-btn, #distill-long-btn, #distill-all-btn, #distill-rebuild-btn'
);
function showDistillStatus(msg, isErr) {
distillStatus.textContent = msg;
distillStatus.classList.toggle('err', !!isErr);
distillStatus.classList.add('show');
setTimeout(() => distillStatus.classList.remove('show'), isErr ? 8000 : 5000);
}
async function runDistill(endpoint, label) {
_distillBtns().forEach(b => { b.disabled = true; });
showDistillStatus(`${label || endpoint} running…`, false);
try {
const res = await fetch(`/distill/${endpoint}?${_fileParams}`, { method: 'POST' });
const d = await res.json();
if (res.status === 409 || res.status === 429) {
showDistillStatus(`${d.detail}`, true);
} else if (!res.ok || d.ok === false) {
const err = d.detail || d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;
showDistillStatus(`${err}`, true);
} else {
showDistillStatus(`${label || endpoint} complete`, false);
}
} catch (err) {
showDistillStatus(`${err.message}`, true);
} finally {
_distillBtns().forEach(b => { b.disabled = false; });
}
}
document.getElementById('distill-short-btn').addEventListener('click', () => runDistill('short', 'Short distill'));
document.getElementById('distill-mid-btn').addEventListener('click', () => runDistill('mid', 'Mid distill'));
document.getElementById('distill-long-btn').addEventListener('click', () => runDistill('long', 'Long distill'));
document.getElementById('distill-all-btn').addEventListener('click', () => runDistill('all', 'Full distill'));
document.getElementById('distill-rebuild-btn').addEventListener('click', () => {
if (!confirm('Rebuild memory from scratch?\n\nThis will wipe MEMORY_MID and MEMORY_LONG (backups kept) then regenerate them from session logs. Any hand-edited content will be replaced.\n\nContinue?')) return;
runDistill('rebuild', 'Memory rebuild');
});
updateTierUI();
updateMemUI();
updateHeightUI();
updateToolsToggleUI();
update_mode_ui();
// ── Init ─────────────────────────────────────────────────────
updateEnterToggleUI();
syncHeight();
addMessage('system', 'Session started');
// ── 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);
}
// ── Service worker + Web Push ────────────────────────────────
const pushBtn = document.getElementById('push-btn');
const pushBtnLabel = document.getElementById('push-btn-label');
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
async function _getPushSubscription() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
async function _syncPushBtn() {
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return;
pushBtn.style.display = '';
const sub = await _getPushSubscription();
if (sub) {
pushBtnLabel.textContent = 'Notifications on';
pushBtn.classList.add('push-active');
} else {
pushBtnLabel.textContent = 'Enable notifications';
pushBtn.classList.remove('push-active');
}
}
async function _subscribePush() {
try {
const keyRes = await fetch('/api/push/vapid-key');
if (!keyRes.ok) { showToast('Push not configured on server'); return; }
const { public_key } = await keyRes.json();
const perm = await Notification.requestPermission();
if (perm !== 'granted') { showToast('Notification permission denied'); return; }
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(public_key),
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON() }),
});
showToast('Push notifications enabled');
await _syncPushBtn();
} catch (e) {
showToast('Could not enable push: ' + e.message);
}
}
async function _unsubscribePush() {
try {
const sub = await _getPushSubscription();
if (!sub) { await _syncPushBtn(); return; }
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
showToast('Notifications disabled');
await _syncPushBtn();
} catch (e) {
showToast('Error: ' + e.message);
}
}
if (pushBtn) {
pushBtn.addEventListener('click', async () => {
settings_dd_el.classList.remove('open');
const sub = await _getPushSubscription();
if (sub) await _unsubscribePush();
else await _subscribePush();
});
_syncPushBtn();
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}