🧠 Claude Harness

Multi-user AI workspace

Create an account
💬

General

@claude: ON
No session
0 / 150k tokens 🧠
1

Claude Harness

Rolling context. No compaction. Nothing lost.

150k context
Clips oldest, never compacts
Artifacts
Auto-extracted code blocks
Voice I/O
Mic + TTS, hands-free
Relay
Multi-Claude messaging

Start a new session or select one from the sidebar

Thinking...
0 chars 0s
Dictation Ready
Click Record and start talking. Audio recorded locally, uploaded with retry, transcribed via Whisper on your private server.
Ready
0 selected

⚙ Settings

Generation
Pure Mode Direct API, zero injections
🛠 Coding Mode HUD, edit discipline, handoffs
💡 Extended Thinking
🔊 Read Responses Aloud
🎨 Model
Context & Prompt
📝 Session Notes Persistent notes for this thread
🧠 Persona / Base Prompt
Context Injections Toggle what gets injected
📊 Context Budget Token limits & memory config
Memory Systems
🧠 Semantic Memory
🌌 W-OS Kanji Memory
🔢 Binary States (64)
Interface
🌙 Dark Mode
🔍 Search Messages
Notifications
🔔 Notifications Enabled
🔈 Sound Alerts
💬 Toast Popups
📍 Toast Position
TL
TC
TR
ML
·
MR
BL
BC
BR
100%
8s
50%
Title Bar Flash Tab blinks when unfocused
🌐 Browser Notifications OS-level popups
Test Notification
Color Scheme
Data & Export
💾 Export Session
💫 Export Soul State
💬 Import Soul State
📂 Import ChatGPT Export
Claude Account
🔑 Checking...
Account
💰 Spending Budget
👤 Account Settings

Artifacts

List
Code
Preview
Docs

No artifacts yet. Code blocks over 500 chars are automatically extracted.

`; } else if (lang === 'javascript' || lang === 'js') { iframe.srcdoc = `
`;
            } else {
                // Plain text / code — just show it
                iframe.srcdoc = `
${escapeHtml(content)}
`; } } let artReadActive = false; let artReadingId = null; async function toggleArtifactReadById(artId, btn) { // If already reading this one, stop if (artReadActive && artReadingId === artId) { speechSynthesis.cancel(); artReadActive = false; artReadingId = null; btn.textContent = '🔊'; btn.classList.remove('reading'); return; } // If reading a different one, stop that first if (artReadActive) { speechSynthesis.cancel(); artReadActive = false; // Reset all read buttons document.querySelectorAll('.art-read-btn').forEach(b => { b.textContent = '🔊'; b.classList.remove('reading'); }); } // Fetch the artifact content if (!currentSessionId) return; const resp = await fetch(`/api/artifacts/${currentSessionId}/${artId}`); const data = await resp.json(); if (!data.content) return; // Strip HTML to plain text const tmp = document.createElement('div'); tmp.innerHTML = data.content; tmp.querySelectorAll('script, style, svg').forEach(el => el.remove()); let text = tmp.textContent || tmp.innerText || ''; text = text.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ').trim(); if (!text) return; artReadActive = true; artReadingId = artId; btn.textContent = '⏹'; btn.classList.add('reading'); // Split into chunks at sentence boundaries const chunks = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= 600) { chunks.push(remaining); break; } let splitIdx = remaining.lastIndexOf('. ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('? ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('! ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('\n', 600); if (splitIdx < 200) splitIdx = 500; chunks.push(remaining.slice(0, splitIdx + 1)); remaining = remaining.slice(splitIdx + 1).trimStart(); } chunks.forEach((chunk, i) => { const utter = new SpeechSynthesisUtterance(chunk); utter.rate = 1.0; utter.pitch = 1.0; if (ttsVoice) utter.voice = ttsVoice; if (i === chunks.length - 1) { utter.onend = () => { artReadActive = false; artReadingId = null; btn.textContent = '🔊'; btn.classList.remove('reading'); }; } speechSynthesis.speak(utter); }); } async function toggleUploadRead(encodedName, btn) { // If already reading this upload, stop if (artReadActive && artReadingId === 'upload_' + encodedName) { speechSynthesis.cancel(); artReadActive = false; artReadingId = null; btn.textContent = '🔊'; btn.classList.remove('reading'); return; } // If reading something else, stop that first if (artReadActive) { speechSynthesis.cancel(); artReadActive = false; document.querySelectorAll('.art-read-btn').forEach(b => { b.textContent = '🔊'; b.classList.remove('reading'); }); } if (!currentSessionId) return; btn.textContent = '⏳'; try { const resp = await fetch(`/api/sessions/${currentSessionId}/uploads/${encodedName}/text`); if (!resp.ok) { const err = await resp.json().catch(() => ({})); btn.textContent = '🔊'; alert(err.detail || 'Could not extract text from this file'); return; } const data = await resp.json(); let text = (data.text || '').replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ').trim(); if (!text) { btn.textContent = '🔊'; return; } artReadActive = true; artReadingId = 'upload_' + encodedName; btn.textContent = '⏹'; btn.classList.add('reading'); const chunks = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= 600) { chunks.push(remaining); break; } let splitIdx = remaining.lastIndexOf('. ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('? ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('! ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('\n', 600); if (splitIdx < 200) splitIdx = 500; chunks.push(remaining.slice(0, splitIdx + 1)); remaining = remaining.slice(splitIdx + 1).trimStart(); } chunks.forEach((chunk, i) => { const utter = new SpeechSynthesisUtterance(chunk); utter.rate = 1.0; utter.pitch = 1.0; if (ttsVoice) utter.voice = ttsVoice; if (i === chunks.length - 1) { utter.onend = () => { artReadActive = false; artReadingId = null; btn.textContent = '🔊'; btn.classList.remove('reading'); }; } speechSynthesis.speak(utter); }); } catch(e) { btn.textContent = '🔊'; console.error('Upload TTS error:', e); } } function toggleArtifactRead() { const btn = document.getElementById('artReadBtn'); if (artReadActive) { // Stop reading speechSynthesis.cancel(); artReadActive = false; btn.textContent = '🔊 Read'; btn.style.borderColor = 'var(--border)'; btn.style.color = 'var(--text-dim)'; return; } if (!currentArtifactData || !currentArtifactData.content) return; // Strip HTML tags to get plain text const tmp = document.createElement('div'); tmp.innerHTML = currentArtifactData.content; // Remove script/style elements entirely tmp.querySelectorAll('script, style, svg').forEach(el => el.remove()); let text = tmp.textContent || tmp.innerText || ''; // Clean up excessive whitespace text = text.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' ').trim(); if (!text) return; artReadActive = true; btn.textContent = '⏹ Stop'; btn.style.borderColor = 'var(--accent)'; btn.style.color = 'var(--accent)'; // Split into chunks (~500 chars at sentence boundaries) so TTS doesn't choke on huge text const chunks = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= 600) { chunks.push(remaining); break; } // Find a sentence boundary near 500 chars let splitIdx = remaining.lastIndexOf('. ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('? ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('! ', 600); if (splitIdx < 200) splitIdx = remaining.lastIndexOf('\n', 600); if (splitIdx < 200) splitIdx = 500; chunks.push(remaining.slice(0, splitIdx + 1)); remaining = remaining.slice(splitIdx + 1).trimStart(); } // Queue all chunks chunks.forEach((chunk, i) => { const utter = new SpeechSynthesisUtterance(chunk); utter.rate = 1.0; utter.pitch = 1.0; if (ttsVoice) utter.voice = ttsVoice; if (i === chunks.length - 1) { utter.onend = () => { artReadActive = false; btn.textContent = '🔊 Read'; btn.style.borderColor = 'var(--border)'; btn.style.color = 'var(--text-dim)'; }; } speechSynthesis.speak(utter); }); } async function deleteArtifact(artId, title) { if (!currentSessionId) return; if (!confirm(`Delete artifact "${title}"?`)) return; try { const r = await fetch(`/api/artifacts/${currentSessionId}/${artId}`, { method: 'DELETE' }); if (!r.ok) { const e = await r.json().catch(() => ({ detail: r.statusText })); alert('Delete failed: ' + (e.detail || 'Unknown error')); return; } artifacts = artifacts.filter(a => a.id !== artId); renderArtifacts(); } catch (err) { alert('Delete failed: ' + err.message); } } async function deleteUpload(encodedName, displayName) { if (!currentSessionId) return; if (!confirm(`Delete upload "${displayName}"?`)) return; try { const r = await fetch(`/api/sessions/${currentSessionId}/uploads/${encodedName}`, { method: 'DELETE' }); if (!r.ok) { const e = await r.json().catch(() => ({ detail: r.statusText })); alert('Delete failed: ' + (e.detail || 'Unknown error')); return; } sessionUploads = sessionUploads.filter(u => encodeURIComponent(u.name) !== encodedName); renderArtifacts(); } catch (err) { alert('Delete failed: ' + err.message); } } function downloadArtifact(artId) { if (!currentSessionId) return; const a = document.createElement('a'); a.href = `/api/artifacts/${currentSessionId}/${artId}/download`; a.download = ''; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function downloadUpload(encodedName) { if (!currentSessionId) return; const a = document.createElement('a'); a.href = `/api/sessions/${currentSessionId}/uploads/${encodedName}`; a.download = ''; document.body.appendChild(a); a.click(); document.body.removeChild(a); } async function viewUpload(encodedName) { if (!currentSessionId) return; const filename = decodeURIComponent(encodedName); const ext = filename.split('.').pop().toLowerCase(); // For binary formats, just download const binaryExts = ['docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'pdf', 'zip', 'gz', 'tar', 'rar', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp3', 'mp4', 'wav', 'avi', 'mov']; if (binaryExts.includes(ext)) { downloadUpload(encodedName); return; } // For text-ish files, fetch and show in code viewer try { const resp = await fetch(`/api/sessions/${currentSessionId}/uploads/${encodedName}`); const text = await resp.text(); currentArtifactData = { content: text, meta: { language: ext, title: filename } }; document.getElementById('artifactCode').textContent = text; hljs.highlightElement(document.getElementById('artifactCode')); showArtifactTab('viewer'); document.getElementById('artReadBtn').style.display = 'inline-block'; } catch(e) { downloadUpload(encodedName); } } function showArtifactTab(tab, tabEl) { document.getElementById('artifactList').style.display = tab === 'list' ? 'block' : 'none'; document.getElementById('artifactViewer').style.display = tab === 'viewer' ? 'block' : 'none'; document.getElementById('artifactPreview').style.display = tab === 'preview' ? 'flex' : 'none'; document.getElementById('docPanel').style.display = tab === 'docs' ? 'flex' : 'none'; document.querySelectorAll('.artifact-tab').forEach(t => t.classList.remove('active')); if (tabEl) tabEl.classList.add('active'); else { const tabs = document.querySelectorAll('.artifact-tab'); const tabMap = {list: 0, viewer: 1, preview: 2, docs: 3, molt: 4}; if (tabs[tabMap[tab]]) tabs[tabMap[tab]].classList.add('active'); } if (tab === 'docs') loadDocuments(); } function toggleArtifactPanel() { const panel = document.getElementById('artifactPanel'); // If fullscreen, exit fullscreen first if (panel.classList.contains('fullscreen')) { panel.classList.remove('fullscreen'); document.getElementById('artFullscreenBtn').textContent = '⛶'; } const isMobile = window.innerWidth <= 768; if (isMobile) { const opening = !panel.classList.contains('mobile-open'); panel.classList.toggle('mobile-open'); panel.classList.remove('collapsed'); document.getElementById('mobileOverlay').classList.toggle('active', opening); } else { panel.classList.toggle('collapsed'); } } function toggleArtifactFullscreen() { const panel = document.getElementById('artifactPanel'); const btn = document.getElementById('artFullscreenBtn'); const isFs = panel.classList.toggle('fullscreen'); btn.textContent = isFs ? '⮌' : '⛶'; btn.title = isFs ? 'Exit fullscreen' : 'Fullscreen'; // Make sure panel is visible panel.classList.remove('collapsed'); } // ESC exits artifact fullscreen document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const panel = document.getElementById('artifactPanel'); if (panel && panel.classList.contains('fullscreen')) { toggleArtifactFullscreen(); } } }); // ── Document Editor ────────────────────────────────── async function loadDocuments() { if (!currentSessionId) return; try { const resp = await fetch(`/api/sessions/${currentSessionId}/documents`); const data = await resp.json(); sessionDocuments = data.documents || []; renderDocList(); } catch(e) { console.error('loadDocuments:', e); } } function renderDocList() { const el = document.getElementById('docList'); if (!sessionDocuments.length) { el.innerHTML = '

No documents yet.

'; return; } const sorted = [...sessionDocuments].sort((a, b) => (b.modified || b.created).localeCompare(a.modified || a.created)); el.innerHTML = sorted.map(d => `
${d.title || 'Untitled'}
`).join(''); } async function createDocument() { if (!currentSessionId) return; const title = prompt('Document title:', 'Untitled'); if (title === null) return; try { const resp = await fetch(`/api/sessions/${currentSessionId}/documents`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ title: title || 'Untitled', content: '' }), }); const data = await resp.json(); await loadDocuments(); openDocument(data.doc_id); } catch(e) { console.error('createDocument:', e); } } async function openDocument(docId) { if (!currentSessionId) return; currentDocId = docId; try { const resp = await fetch(`/api/sessions/${currentSessionId}/documents/${docId}`); const data = await resp.json(); document.getElementById('docEditorArea').style.display = 'flex'; document.getElementById('docTitleInput').value = data.meta.title || ''; document.getElementById('docTextarea').value = data.content || ''; document.getElementById('docStatus').textContent = `Last saved: ${new Date(data.meta.modified).toLocaleTimeString()}`; renderDocList(); // Set up autosave on input const textarea = document.getElementById('docTextarea'); const titleInput = document.getElementById('docTitleInput'); textarea.oninput = () => scheduleDocSave(docId); titleInput.oninput = () => scheduleDocSave(docId); } catch(e) { console.error('openDocument:', e); } } function scheduleDocSave(docId) { if (docSaveTimer) clearTimeout(docSaveTimer); document.getElementById('docStatus').textContent = 'Unsaved changes...'; docSaveTimer = setTimeout(() => saveDocument(docId), 1000); } async function saveDocument(docId) { if (!currentSessionId) return; const content = document.getElementById('docTextarea').value; const title = document.getElementById('docTitleInput').value; try { await fetch(`/api/sessions/${currentSessionId}/documents/${docId}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content, title }), }); document.getElementById('docStatus').textContent = `Saved: ${new Date().toLocaleTimeString()}`; } catch(e) { document.getElementById('docStatus').textContent = 'Save failed!'; console.error('saveDocument:', e); } } async function deleteDocument(docId) { if (!confirm('Delete this document?')) return; try { await fetch(`/api/sessions/${currentSessionId}/documents/${docId}`, { method: 'DELETE' }); if (currentDocId === docId) { currentDocId = null; document.getElementById('docEditorArea').style.display = 'none'; } await loadDocuments(); } catch(e) { console.error('deleteDocument:', e); } } let docTtsPlaying = false; function toggleDocTTS() { const btn = document.getElementById('docTtsBtn'); if (docTtsPlaying) { // Stop speechSynthesis.cancel(); docTtsPlaying = false; btn.innerHTML = '▶'; btn.classList.remove('playing'); btn.title = 'Read document aloud'; return; } // Play const text = document.getElementById('docTextarea').value; if (!text.trim()) return; docTtsPlaying = true; btn.innerHTML = '■'; btn.classList.add('playing'); btn.title = 'Stop reading'; speakRaw(text, () => { docTtsPlaying = false; btn.innerHTML = '▶'; btn.classList.remove('playing'); btn.title = 'Read document aloud'; }); } function toggleMobileSidebar() { const sidebar = document.querySelector('.sidebar'); const opening = !sidebar.classList.contains('open'); sidebar.classList.toggle('open'); document.getElementById('mobileOverlay').classList.toggle('active', opening); } function closeMobilePanels() { document.querySelector('.sidebar').classList.remove('open'); document.getElementById('artifactPanel').classList.remove('mobile-open'); const rp = document.getElementById('relayPanel'); rp.classList.remove('mobile-open'); rp.classList.add('collapsed'); stopRelayPoll(); document.getElementById('mobileOverlay').classList.remove('active'); } // ── Utilities ─────────────────────────────── let _userScrolledUp = false; let _programmaticScroll = false; document.getElementById('chatArea').addEventListener('scroll', function() { if (_programmaticScroll) return; // ignore scrolls we triggered const chat = this; _userScrolledUp = (chat.scrollHeight - chat.scrollTop - chat.clientHeight) > 150; }); // Throttled version for use during streaming — max once per 150ms let _scrollThrottled = false; function _throttledScroll() { if (_scrollThrottled) return; _scrollThrottled = true; scrollToBottom(); setTimeout(() => { _scrollThrottled = false; }, 150); } function scrollToBottom(force = false) { if (_userScrolledUp && !force) return; requestAnimationFrame(() => { const chat = document.getElementById('chatArea'); _programmaticScroll = true; chat.scrollTo({ top: chat.scrollHeight, behavior: 'smooth' }); // Pin body at top — never let chatArea scroll cause page movement window.scrollTo(0, 0); document.documentElement.scrollTop = 0; // Reset flag after scroll animation settles setTimeout(() => { _programmaticScroll = false; }, 500); }); } // ── File Upload ─────────────────────────────── let pendingFiles = []; // Files waiting to be uploaded with the next message function handleFileSelect(event) { const files = Array.from(event.target.files); files.forEach(file => { pendingFiles.push(file); renderAttachedFiles(); }); event.target.value = ''; // reset so same file can be re-selected } function renderAttachedFiles() { const container = document.getElementById('attachedFiles'); container.innerHTML = ''; if (pendingFiles.length === 0) { container.style.display = 'none'; return; } container.style.display = 'flex'; pendingFiles.forEach((file, i) => { const div = document.createElement('div'); div.className = 'attached-file'; const sizeStr = file.size < 1024 ? file.size + 'B' : file.size < 1048576 ? (file.size / 1024).toFixed(1) + 'KB' : (file.size / 1048576).toFixed(1) + 'MB'; const isImage = file.type && file.type.startsWith('image/'); if (isImage) { const thumb = document.createElement('img'); thumb.style.cssText = 'max-height:48px;max-width:64px;border-radius:4px;margin-right:6px;vertical-align:middle;'; thumb.src = URL.createObjectURL(file); thumb.onload = () => URL.revokeObjectURL(thumb.src); div.appendChild(thumb); } const info = document.createElement('span'); info.innerHTML = `${isImage ? '' : '📄 '}${file.name} ${sizeStr}`; div.appendChild(info); const remove = document.createElement('span'); remove.className = 'remove-file'; remove.innerHTML = '×'; remove.onclick = () => removePendingFile(i); div.appendChild(remove); container.appendChild(div); }); } function removePendingFile(index) { pendingFiles.splice(index, 1); renderAttachedFiles(); } async function uploadPendingFiles() { if (!pendingFiles.length || !currentSessionId) return []; const uploaded = []; for (const file of pendingFiles) { const formData = new FormData(); formData.append('file', file); try { const resp = await fetch(`/api/sessions/${currentSessionId}/upload`, { method: 'POST', body: formData, }); const data = await resp.json(); if (data.ok) { uploaded.push(data); } } catch(e) { console.error('Upload failed:', e); } } pendingFiles = []; renderAttachedFiles(); return uploaded; } async function loadSessionUploads() { if (!currentSessionId) return; try { const resp = await fetch(`/api/sessions/${currentSessionId}/uploads`); const data = await resp.json(); sessionUploads = data.files || []; renderArtifacts(); renderSessionUploads(sessionUploads); } catch(e) {} } function renderSessionUploads(files) { let section = document.getElementById('uploadsSection'); if (!section) { // Create it under the chat area, above input section = document.createElement('div'); section.id = 'uploadsSection'; section.className = 'uploads-section'; const inputArea = document.querySelector('.input-area'); inputArea.parentNode.insertBefore(section, inputArea); } if (!files.length) { section.style.display = 'none'; return; } section.style.display = ''; const sizeStr = (sz) => sz < 1024 ? sz + 'B' : sz < 1048576 ? (sz / 1024).toFixed(1) + 'KB' : (sz / 1048576).toFixed(1) + 'MB'; section.innerHTML = `

Uploads (${files.length})

` + files.map(f => `
${f.name} ${sizeStr(f.size)}
`).join(''); } // ── Dictation: Guaranteed-Delivery STT ────── // Records locally, uploads chunks via HTTP with retry, server assembles + transcribes. // No audio is ever lost — IndexedDB backup + server-side storage + re-scan ability. const STT_API = 'https://voice.akataleptos.com'; let _dictating = false; let _dictRecorder = null; let _dictStream = null; let _dictTranscript = ''; // accumulated transcript (preview during recording, final after) let _dictFinalTranscript = ''; // final full-audio transcript (replaces preview) let _dictChunkCount = 0; let _dictChunkTimer = null; let _dictPendingUploads = 0; // chunks being uploaded let _dictDraining = false; // mic stopped, uploading/finalizing let _dictUserStopped = false; let DICT_CHUNK_MS = 5000; let _sttMode = '3'; let _inlineMode = false; let _dictPreText = ''; let _dictRecordingId = null; // current recording session UUID let _dictChunkQueue = []; // [{seq, blob, uploaded}] let _dictUploadActive = false; // upload loop running let _dictGeneration = 0; // increments each new recording — stale loops auto-stop let _lastRecordingId = null; // for re-scan // ── IndexedDB for local audio backup ── let _dictDB = null; (function openDictDB() { const req = indexedDB.open('pantheonic_stt', 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('chunks')) { const store = db.createObjectStore('chunks', { keyPath: ['recording_id', 'seq'] }); store.createIndex('by_recording', 'recording_id'); } }; req.onsuccess = (e) => { _dictDB = e.target.result; }; req.onerror = () => { console.warn('[STT] IndexedDB unavailable — no local backup'); }; })(); function _dictSaveChunkLocal(recording_id, seq, blob) { if (!_dictDB) return; try { const tx = _dictDB.transaction('chunks', 'readwrite'); tx.objectStore('chunks').put({ recording_id, seq, blob, uploaded: false, ts: Date.now() }); } catch(e) { console.warn('[STT] IndexedDB write failed:', e); } } function _dictMarkChunkUploaded(recording_id, seq) { if (!_dictDB) return; try { const tx = _dictDB.transaction('chunks', 'readwrite'); const store = tx.objectStore('chunks'); const req = store.get([recording_id, seq]); req.onsuccess = () => { if (req.result) { req.result.uploaded = true; store.put(req.result); } }; } catch(e) {} } async function _dictGetLocalChunk(recording_id, seq) { if (!_dictDB) return null; return new Promise((resolve) => { try { const tx = _dictDB.transaction('chunks', 'readonly'); const req = tx.objectStore('chunks').get([recording_id, seq]); req.onsuccess = () => resolve(req.result?.blob || null); req.onerror = () => resolve(null); } catch(e) { resolve(null); } }); } function _dictCleanupLocalDB(recording_id) { // Remove chunks older than 2 hours (keep recent for re-scan) if (!_dictDB) return; try { const cutoff = Date.now() - (2 * 60 * 60 * 1000); const tx = _dictDB.transaction('chunks', 'readwrite'); const store = tx.objectStore('chunks'); store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (cursor) { if (cursor.value.ts < cutoff) cursor.delete(); cursor.continue(); } }; } catch(e) {} } // ── UI helpers (same as before) ── function _dictStatusEl() { return document.getElementById(_inlineMode ? 'dictInlineStatus' : 'dictStatus'); } function _dictChunkEl() { return document.getElementById(_inlineMode ? 'dictInlineChunkInfo' : 'dictChunkInfo'); } function _dictRecDotEl() { return document.getElementById(_inlineMode ? 'dictInlineRecDot' : 'dictRecDot'); } function _dictSetTranscript(isFinal) { const text = isFinal ? _dictFinalTranscript : _dictTranscript; if (_inlineMode) { const input = document.getElementById('userInput'); input.value = _dictPreText + (_dictPreText && text ? ' ' : '') + text; autoResize(input); } else { const area = document.getElementById('dictTranscript'); const cls = isFinal ? 'final' : 'preview'; area.innerHTML = `${_escHtml(text)}`; if (isFinal) area.innerHTML += '
[Full-audio transcription]'; area.scrollTop = area.scrollHeight; } } function _dictSetRecording(recording) { if (_inlineMode) { document.getElementById('micBtn').textContent = recording ? '⏹' : '🎤'; const ta = document.getElementById('userInput'); ta.classList.remove('dictating', 'dict-draining', 'dict-ready'); if (recording) ta.classList.add('dictating'); } else { const btn = document.getElementById('dictRecBtn'); btn.textContent = recording ? 'STOP' : 'RECORD'; btn.classList.toggle('recording', recording); } _dictRecDotEl().classList.toggle('recording', recording); } function setSttMode(mode) { _sttMode = mode; ['stt1','stt2','stt3','sttI1','sttI2','sttI3'].forEach(id => { const el = document.getElementById(id); if (el) { el.classList.remove('active'); el.style.background = '#333'; el.style.color = '#aaa'; } }); ['stt' + mode, 'sttI' + mode].forEach(id => { const el = document.getElementById(id); if (el) { el.classList.add('active'); el.style.background = mode === '3' ? '#c70' : mode === '2' ? '#0aa' : '#555'; el.style.color = '#fff'; } }); if (mode === '1') { DICT_CHUNK_MS = 4000; } else { DICT_CHUNK_MS = 5000; } const label = mode === '1' ? '1x (fast)' : mode === '2' ? '2x (accurate)' : '3x (max accuracy)'; _dictStatusEl().textContent = label; setTimeout(() => { _dictStatusEl().textContent = 'Ready'; }, 2000); } // ── Upload engine: HTTP POST with retry ── async function _dictUploadChunk(recording_id, seq, blob, retries) { retries = retries || 0; const maxRetries = 5; const form = new FormData(); form.append('recording_id', recording_id); form.append('seq', seq.toString()); form.append('audio', blob, `chunk_${seq}.webm`); try { const resp = await fetch(`${STT_API}/api/stt/chunk`, { method: 'POST', body: form, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); _dictMarkChunkUploaded(recording_id, seq); _dictPendingUploads = Math.max(0, _dictPendingUploads - 1); // Show preview text from quick 1-pass STT if (data.preview) { _dictTranscript += (_dictTranscript ? ' ' : '') + data.preview; _dictSetTranscript(false); } const sz = (blob.size / 1024).toFixed(1); _dictChunkEl().textContent = `${data.received.length} chunks (${sz}KB)`; if (_dictating) _dictStatusEl().textContent = 'Listening...'; else if (_dictDraining) { _dictStatusEl().textContent = _dictPendingUploads > 0 ? `Uploading... (${_dictPendingUploads} left)` : 'Finalizing...'; } console.log(`[STT] chunk ${seq} uploaded (${sz}KB), ${data.received.length} on server`); return data; } catch(err) { if (retries < maxRetries) { const delay = Math.min(1000 * Math.pow(1.5, retries), 8000); console.warn(`[STT] chunk ${seq} upload failed (retry ${retries+1}/${maxRetries}): ${err.message}`); _dictStatusEl().textContent = `Retry upload ${seq}...`; await new Promise(r => setTimeout(r, delay)); return _dictUploadChunk(recording_id, seq, blob, retries + 1); } console.error(`[STT] chunk ${seq} upload FAILED after ${maxRetries} retries:`, err); _dictPendingUploads = Math.max(0, _dictPendingUploads - 1); return null; } } async function _dictUploadLoop() { if (_dictUploadActive) return; _dictUploadActive = true; const gen = _dictGeneration; // capture current generation while (_dictChunkQueue.length > 0) { if (gen !== _dictGeneration) break; // new recording started — bail const item = _dictChunkQueue.shift(); if (!item.uploaded) { await _dictUploadChunk(item.recording_id, item.seq, item.blob); } if (gen !== _dictGeneration) break; // check again after upload } _dictUploadActive = false; // If draining and all uploads done, finalize (only if still same recording) if (gen === _dictGeneration && _dictDraining && _dictPendingUploads <= 0) { await _dictFinalize(); } } // ── Finalize: assemble + full STT on server ── async function _dictFinalize() { if (!_dictRecordingId) { _finishDraining(); return; } _dictStatusEl().textContent = 'Finalizing...'; try { const resp = await fetch(`${STT_API}/api/stt/finalize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recording_id: _dictRecordingId, total_chunks: _dictChunkCount, mode: _sttMode, }), }); const data = await resp.json(); if (data.status === 'incomplete' && data.missing?.length > 0) { console.warn('[STT] Missing chunks:', data.missing); _dictStatusEl().textContent = `Re-uploading ${data.missing.length} chunks...`; // Re-upload missing chunks from IndexedDB for (const seq of data.missing) { const blob = await _dictGetLocalChunk(_dictRecordingId, seq); if (blob) { _dictPendingUploads++; _dictChunkQueue.push({ recording_id: _dictRecordingId, seq, blob, uploaded: false }); } else { console.error(`[STT] Missing chunk ${seq} not in local DB — audio lost`); } } if (_dictChunkQueue.length > 0) { _dictUploadActive = false; _dictUploadLoop(); // will re-finalize when done return; } // All missing chunks are truly lost — use preview text _dictStatusEl().textContent = 'Done (some audio lost)'; _finishDraining(); return; } if (data.status === 'done' && data.transcript) { _dictFinalTranscript = data.transcript; _dictTranscript = data.transcript; // replace preview with final _dictSetTranscript(true); console.log(`[STT] Final transcript (${data.chunks_used} chunks): ${data.transcript.length} chars`); } else if (data.status === 'error') { console.error('[STT] Finalization error:', data.error); // Fall back to preview text if (data.fallback_transcript) { _dictTranscript = data.fallback_transcript; _dictSetTranscript(false); } } } catch(err) { console.error('[STT] Finalize failed:', err); // Preview text remains as fallback } _lastRecordingId = _dictRecordingId; _finishDraining(); } // ── Core dictation controls ── function toggleMic() { if (_dictating) { _dictUserStopped = true; stopDictation(); return; } if (_dictDraining) { cancelDictation(); setTimeout(() => { toggleMic(); }, 200); return; } _inlineMode = true; const ta = document.getElementById('userInput'); // Always clear stale state from previous recording _dictTranscript = ''; _dictFinalTranscript = ''; if (ta.classList.contains('dict-ready')) { ta.value = ''; ta.classList.remove('dict-ready'); } _dictPreText = ta.value; document.getElementById('dictInlineBar').classList.add('active'); document.getElementById('micBtn').classList.add('active'); toggleDictation(); } function toggleDictationPanel() { const panel = document.getElementById('dictationPanel'); const isOpen = panel.classList.contains('active'); if (isOpen) { if (_dictating) stopDictation(); panel.classList.remove('active'); } else { _inlineMode = false; panel.classList.add('active'); } } async function toggleDictation() { if (_dictating) { stopDictation(); return; } // Get mic try { _dictStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: {ideal: 16000}, channelCount: {ideal: 1} } }); } catch (err1) { try { _dictStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err2) { _dictStatusEl().textContent = 'Mic denied — check browser permissions'; console.error('getUserMedia failed:', err1, err2); return; } } // Generate recording session ID _dictRecordingId = crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)); _dictGeneration++; // kill any stale upload loops from previous recordings _dictUploadActive = false; _dictating = true; _dictChunkCount = 0; _dictChunkQueue = []; _dictPendingUploads = 0; _dictTranscript = ''; _dictFinalTranscript = ''; _dictSetRecording(true); _dictStatusEl().textContent = 'Listening...'; // Clear panel placeholder if (!_inlineMode) { const area = document.getElementById('dictTranscript'); if (_dictTranscript === '' || area.querySelector('.placeholder')) { area.innerHTML = ''; _dictTranscript = ''; } } else { _dictTranscript = ''; } _startDictChunk(); console.log(`[STT] Recording started: ${_dictRecordingId}`); } function _startDictChunk() { if (!_dictating || !_dictStream) return; const options = { mimeType: 'audio/webm;codecs=opus' }; if (!MediaRecorder.isTypeSupported(options.mimeType)) { for (const fb of ['audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', '']) { if (!fb || MediaRecorder.isTypeSupported(fb)) { options.mimeType = fb; break; } } } try { _dictRecorder = options.mimeType ? new MediaRecorder(_dictStream, options) : new MediaRecorder(_dictStream); } catch(e) { try { _dictRecorder = new MediaRecorder(_dictStream); } catch(e2) { stopDictation(); return; } } _dictRecorder.ondataavailable = (e) => { if (e.data.size > 0) { const seq = _dictChunkCount; _dictChunkCount++; _dictPendingUploads++; const blob = e.data; const sz = (blob.size / 1024).toFixed(1); _dictChunkEl().textContent = `chunk ${seq + 1} (${sz}KB)`; _dictStatusEl().textContent = 'Uploading...'; // Save locally first (never lose audio) _dictSaveChunkLocal(_dictRecordingId, seq, blob); // Queue for upload _dictChunkQueue.push({ recording_id: _dictRecordingId, seq: seq, blob: blob, uploaded: false, }); // Start upload loop if not running _dictUploadLoop(); } }; _dictRecorder.onstop = () => { if (_dictating) _startDictChunk(); }; _dictRecorder.start(); _dictChunkTimer = setTimeout(() => { if (_dictRecorder?.state === 'recording') _dictRecorder.stop(); }, DICT_CHUNK_MS); } function stopDictation() { _dictating = false; if (_dictChunkTimer) { clearTimeout(_dictChunkTimer); _dictChunkTimer = null; } if (_dictRecorder?.state === 'recording') try { _dictRecorder.stop(); } catch(e) {} _dictRecorder = null; if (_dictStream) { _dictStream.getTracks().forEach(t => t.stop()); _dictStream = null; } _dictSetRecording(false); if (_inlineMode) { const ta = document.getElementById('userInput'); ta.classList.remove('dictating'); ta.classList.add('dict-draining'); } _dictDraining = true; if (_dictPendingUploads > 0) { _dictStatusEl().textContent = `Uploading... (${_dictPendingUploads} left)`; document.getElementById('dictCancelBtn').style.display = 'inline-block'; // Upload loop will call _dictFinalize when done } else { // All chunks already uploaded — finalize immediately _dictFinalize(); } } function cancelDictation() { _dictating = false; _dictDraining = false; _dictUserStopped = false; _dictPendingUploads = 0; _dictGeneration++; // kill any in-flight upload loop _dictUploadActive = false; _dictChunkQueue = []; if (_dictChunkTimer) { clearTimeout(_dictChunkTimer); _dictChunkTimer = null; } if (_dictRecorder?.state === 'recording') try { _dictRecorder.stop(); } catch(e) {} _dictRecorder = null; if (_dictStream) { _dictStream.getTracks().forEach(t => t.stop()); _dictStream = null; } _dictSetRecording(false); document.getElementById('dictCancelBtn').style.display = 'none'; _dictStatusEl().textContent = 'Cancelled'; _lastRecordingId = _dictRecordingId; if (_inlineMode) { const ta = document.getElementById('userInput'); ta.classList.remove('dictating', 'dict-draining'); if (_dictTranscript) { ta.classList.add('dict-ready'); } else { _dictCleanupInline(); } } } function _finishDraining() { _dictDraining = false; _dictPendingUploads = 0; document.getElementById('dictCancelBtn').style.display = 'none'; _dictStatusEl().textContent = 'Done'; // Show re-scan buttons (both inline and panel) if (_lastRecordingId) { const rb1 = document.getElementById('dictRescanBtn'); const rb2 = document.getElementById('dictRescanBtnPanel'); if (rb1) rb1.style.display = 'inline-block'; if (rb2) rb2.style.display = 'inline-block'; } if (_inlineMode) { const ta = document.getElementById('userInput'); ta.classList.remove('dictating', 'dict-draining'); if (_dictUserStopped && document.getElementById('dictAutoSend').checked && _dictTranscript) { _dictUserStopped = false; _dictCleanupInline(); handleSendBtn(); } else if (_dictTranscript) { ta.classList.add('dict-ready'); ta.focus(); const removeReady = () => { ta.classList.remove('dict-ready'); ta.removeEventListener('input', removeReady); }; ta.addEventListener('input', removeReady); } else { _dictCleanupInline(); } } // Cleanup old IndexedDB entries _dictCleanupLocalDB(); } function _dictCleanupInline() { _inlineMode = false; _dictPreText = ''; _dictTranscript = ''; _dictFinalTranscript = ''; _dictChunkCount = 0; _dictUserStopped = false; _dictRecordingId = null; document.getElementById('dictInlineBar').classList.remove('active'); document.getElementById('dictCancelBtn').style.display = 'none'; ['dictRescanBtn', 'dictRescanBtnPanel'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); document.getElementById('micBtn').classList.remove('active'); document.getElementById('micBtn').textContent = '🎤'; const ta = document.getElementById('userInput'); ta.classList.remove('dictating', 'dict-draining', 'dict-ready'); document.getElementById('dictInlineChunkInfo').textContent = ''; document.getElementById('dictInlineStatus').textContent = 'Ready'; } // ── Re-scan: re-transcribe stored audio on server ── async function rescanDictation() { const rid = _lastRecordingId; if (!rid) return; _dictStatusEl().textContent = 'Re-scanning...'; try { const resp = await fetch(`${STT_API}/api/stt/rescan/${rid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: _sttMode }), }); const data = await resp.json(); if (data.status === 'done' && data.transcript) { _dictFinalTranscript = data.transcript; _dictTranscript = data.transcript; _dictSetTranscript(true); _dictStatusEl().textContent = 'Re-scanned'; console.log(`[STT] Re-scan: ${data.transcript.length} chars`); } else { _dictStatusEl().textContent = 'Re-scan failed'; } } catch(err) { console.error('[STT] Re-scan failed:', err); _dictStatusEl().textContent = 'Re-scan error'; } } function copyDictation() { const text = _dictFinalTranscript || _dictTranscript; if (text) { navigator.clipboard.writeText(text).then(() => { _dictStatusEl().textContent = 'Copied!'; setTimeout(() => { _dictStatusEl().textContent = 'Done'; }, 1500); }); } } function clearDictation() { if (_dictDraining) _finishDraining(); if (_inlineMode) { _dictCleanupInline(); } else { _dictTranscript = ''; _dictFinalTranscript = ''; _dictChunkCount = 0; _dictPendingUploads = 0; document.getElementById('dictTranscript').innerHTML = 'Click Record and start talking. Audio recorded locally, uploaded with retry, transcribed via Whisper on your private server.'; document.getElementById('dictChunkInfo').textContent = ''; document.getElementById('dictStatus').textContent = 'Ready'; ['dictRescanBtn', 'dictRescanBtnPanel'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); } } function sendDictation() { const text = _dictFinalTranscript || _dictTranscript; if (!text) return; const input = document.getElementById('userInput'); input.value = input.value ? input.value + ' ' + text : text; autoResize(input); input.focus(); clearDictation(); document.getElementById('dictationPanel').classList.remove('active'); document.getElementById('micBtn').classList.remove('active'); } // _escHtml — use existing escapeHtml function _escHtml(s) { return escapeHtml(s); } // ── Voice: Text-to-Speech (TTS) ──────────── let ttsEnabled = false; let ttsVoice = null; // Coqui TTS state let coquiAvailable = false; let coquiVoices = []; let coquiVoiceId = localStorage.getItem('coquiVoiceId') || null; let _coquiAudioQueue = []; let _coquiPlaying = false; let _coquiCurrentAudio = null; function initTTS() { // Browser TTS fallback if (window.speechSynthesis) { function loadVoices() { const voices = speechSynthesis.getVoices(); ttsVoice = voices.find(v => v.name.includes('Google') && v.lang.startsWith('en')) || voices.find(v => v.name.includes('Natural') && v.lang.startsWith('en')) || voices.find(v => v.name.includes('Microsoft') && v.lang.startsWith('en') && v.name.includes('Online')) || voices.find(v => v.lang.startsWith('en')) || voices[0] || null; } loadVoices(); speechSynthesis.onvoiceschanged = loadVoices; } // Probe Coqui initCoquiTTS(); } async function initCoquiTTS() { try { const r = await fetch('/api/tts/health'); const d = await r.json(); coquiAvailable = (d.status === 'online'); if (coquiAvailable) { const vr = await fetch('/api/tts/voices'); const vd = await vr.json(); coquiVoices = vd.voice_ids || vd.available || []; _populateCoquiVoiceSelect(); document.getElementById('coquiBadge').classList.add('active'); console.log('[TTS] Coqui available, voices:', coquiVoices.length); } } catch(e) { coquiAvailable = false; console.log('[TTS] Coqui unavailable, using browser TTS'); } } function _populateCoquiVoiceSelect() { const sel = document.getElementById('coquiVoiceSelect'); sel.innerHTML = ''; coquiVoices.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; if (v === coquiVoiceId) opt.selected = true; sel.appendChild(opt); }); } function setCoquiVoice(voiceId) { coquiVoiceId = voiceId || null; if (voiceId) localStorage.setItem('coquiVoiceId', voiceId); else localStorage.removeItem('coquiVoiceId'); } function toggleTTS() { ttsEnabled = !ttsEnabled; if (typeof saveSettingToServer === 'function') saveSettingToServer('userDefault_ttsEnabled', ttsEnabled); const btn = document.getElementById('ttsBtn'); const dbg = document.getElementById('ttsDebug'); const voiceSel = document.getElementById('coquiVoiceSelect'); if (dbg) dbg.style.display = ttsEnabled ? 'block' : 'none'; if (ttsEnabled) { btn.classList.add('active'); btn.innerHTML = '🔊AI'; btn.title = coquiAvailable ? 'Coqui TTS ON — click to mute' : 'Auto-read ON — click to mute'; if (coquiAvailable) voiceSel.classList.add('active'); } else { btn.classList.remove('active'); btn.innerHTML = '🔈AI'; btn.title = 'Auto-read responses aloud'; voiceSel.classList.remove('active'); stopAllPlayback(); } } // Clean text for TTS (strip markdown, code blocks, etc.) function cleanForTTS(text) { return text .replace(/```[\s\S]*?```/g, '... code block omitted ...') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/#{1,6}\s/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\[ARTIFACT[^\]]*\]/g, '... artifact saved ...') .replace(/\n{2,}/g, '. ') .replace(/\n/g, ' ') .trim(); } // Queue text chunks for speech WITHOUT canceling current playback function queueSpeech(text) { if (!ttsEnabled) return; if (coquiAvailable) return _queueCoquiSpeech(text); _queueBrowserSpeech(text); } function _queueBrowserSpeech(text) { if (!window.speechSynthesis) return; const clean = cleanForTTS(text); if (!clean) return; const dbg = document.getElementById('ttsDebug'); if (dbg) dbg.textContent = `[TTS:browser] queuing ${clean.length} chars`; const sentences = clean.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [clean]; const chunks = []; let current = ''; for (const s of sentences) { if ((current + s).length > 200) { if (current) chunks.push(current.trim()); current = s; } else { current += s; } } if (current) chunks.push(current.trim()); chunks.forEach(chunk => { const utterance = new SpeechSynthesisUtterance(chunk); if (ttsVoice) utterance.voice = ttsVoice; utterance.rate = 1.05; utterance.pitch = 1.0; speechSynthesis.speak(utterance); }); } async function _queueCoquiSpeech(text) { const clean = cleanForTTS(text); if (!clean) return; const dbg = document.getElementById('ttsDebug'); if (dbg) dbg.textContent = `[TTS:coqui] generating ${clean.length} chars...`; try { const r = await fetch('/api/tts', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: clean, speaker_id: coquiVoiceId}) }); if (!r.ok) throw new Error(`TTS ${r.status}`); const blob = await r.blob(); const url = URL.createObjectURL(blob); _coquiAudioQueue.push(url); if (dbg) dbg.textContent = `[TTS:coqui] queued, ${_coquiAudioQueue.length} in queue`; if (!_coquiPlaying) _playCoquiNext(); } catch(e) { console.warn('[TTS] Coqui failed, falling back to browser:', e); if (dbg) dbg.textContent = `[TTS:coqui] error: ${e.message}, using browser`; _queueBrowserSpeech(text); } } function _playCoquiNext() { if (_coquiAudioQueue.length === 0) { _coquiPlaying = false; _coquiCurrentAudio = null; return; } _coquiPlaying = true; const url = _coquiAudioQueue.shift(); const audio = new Audio(url); _coquiCurrentAudio = audio; audio.onended = () => { URL.revokeObjectURL(url); _playCoquiNext(); }; audio.onerror = () => { URL.revokeObjectURL(url); _playCoquiNext(); }; audio.play().catch(() => { URL.revokeObjectURL(url); _playCoquiNext(); }); } function _stopCoquiPlayback() { _coquiAudioQueue.forEach(url => URL.revokeObjectURL(url)); _coquiAudioQueue = []; _coquiPlaying = false; if (_coquiCurrentAudio) { _coquiCurrentAudio.pause(); _coquiCurrentAudio.src = ''; _coquiCurrentAudio = null; } } // Speak text WITH cancel (for replays, new user messages, toggle off) function speakText(text) { console.log('[TTS] speakText called, len=', text?.length, 'ttsEnabled=', ttsEnabled); const dbg = document.getElementById('ttsDebug'); if (dbg) dbg.textContent = `[TTS] speaking ${text?.length || 0} chars, enabled=${ttsEnabled}`; if (!ttsEnabled) return; // Cancel current playback first _stopCoquiPlayback(); if (window.speechSynthesis) speechSynthesis.cancel(); queueSpeech(text); } // Streaming TTS state — tracks what's been spoken during current stream let _ttsStreamSpoken = 0; // how many chars of currentStreamText have been queued for speech // Init voice on load initTTS(); // ── Relay ────────────────────────────────── let relayMessages = []; // relay state let relayPollTimer = null; let relayLastTs = 0; let relayPresenceData = {}; let relayDmTarget = null; // null = general feed, username = DM thread let relayUnread = {}; // {username: count, '__feed__': count} let relayLastMsg = {}; // {username: {text, ts}} — last message preview per contact // Known identities with display info // Hardcoded identities for known relay agents (AI instances) const RELAY_IDENTITIES = { 'obi_claude': { name: 'obi_claude', initials: 'OC', css: 'obi-claude' }, 'friend_claude': { name: 'friend_claude', initials: 'FC', css: 'friend-claude' }, 'mark_claude': { name: 'mark_claude', initials: 'MC', css: 'mark-claude' }, 'dark_claude': { name: 'dark_claude', initials: 'DC', css: 'dark-claude' }, }; // Dynamic identities built from registered users let relayUserIdentities = {}; let relayContactsList = []; function getIdentity(sender) { if (!sender) return { name: '?', initials: '?', css: 'unknown' }; const s = sender.toLowerCase(); // Check dynamic user identities first if (relayUserIdentities[s]) return relayUserIdentities[s]; // Then check hardcoded AI identities for (const [key, val] of Object.entries(RELAY_IDENTITIES)) { if (s.includes(key) || s === key) return val; } return { name: sender, initials: sender.slice(0, 2).toUpperCase(), css: 'unknown' }; } function syncRelayIdentitiesFromContacts() { // Build relay identities from the contacts data for (const u of contactsData) { const initials = (u.display_name || u.username).slice(0, 2).toUpperCase(); relayUserIdentities[u.username.toLowerCase()] = { name: u.display_name || u.username, initials: initials, css: u.online ? 'obi-human' : 'unknown' }; } } function toggleRelayPanel() { const panel = document.getElementById('relayPanel'); const isMobile = window.innerWidth <= 768; if (isMobile) { const opening = !panel.classList.contains('mobile-open'); if (opening) { panel.classList.add('mobile-open'); panel.classList.remove('collapsed'); document.getElementById('mobileOverlay').classList.add('active'); loadRelayMessages(); loadContacts(); startRelayPoll(); updateRelayUI(); clearUnreadForCurrentView(); } else { panel.classList.remove('mobile-open'); panel.classList.add('collapsed'); document.getElementById('mobileOverlay').classList.remove('active'); stopRelayPoll(); } } else { const wasCollapsed = panel.classList.contains('collapsed'); panel.classList.toggle('collapsed'); if (wasCollapsed) { loadRelayMessages(); loadContacts(); startRelayPoll(); updateRelayUI(); clearUnreadForCurrentView(); } else { stopRelayPoll(); } } } function updateRelayUI() { // Update relay header based on current mode if (relayDmTarget) { const contact = contactsData.find(c => c.username === relayDmTarget); const name = contact ? (contact.display_name || relayDmTarget) : relayDmTarget; document.getElementById('relayPanelTitle').textContent = name; document.getElementById('relayBackBtn').style.display = ''; } else { document.getElementById('relayPanelTitle').innerHTML = '📡 General Feed'; document.getElementById('relayBackBtn').style.display = 'none'; } } async function loadRelayPresence() { // Presence data now comes from /api/contacts — no separate bar needed try { const resp = await fetch('/api/relay/presence'); const data = await resp.json(); if (data.presence) { relayPresenceData = {}; data.presence.forEach(p => { relayPresenceData[p.sender] = p; }); } } catch(e) {} } async function loadRelayMessages() { try { const resp = await fetch('/api/relay/history?per_page=100'); const data = await resp.json(); if (data.messages) { relayMessages = data.messages.reverse(); // oldest first if (relayMessages.length) { relayLastTs = Math.max(...relayMessages.map(m => m.ts || 0)); } // Seed last message previews from history const me = (currentUser?.username || '').toLowerCase(); for (const m of relayMessages) { const s = (m.sender || '').toLowerCase(); if (s && s !== me) { relayLastMsg[s] = { text: m.text || '', ts: m.ts_iso || '' }; } } renderRelayMessages(); renderContacts(); // update previews } } catch(e) { console.error('Relay load error:', e); } } async function pollRelayMessages() { try { const resp = await fetch(`/api/relay/poll?since=${relayLastTs}&limit=50`); const data = await resp.json(); if (data.messages && data.messages.length > 0) { let newCount = 0; const newMsgs = []; for (const msg of data.messages) { if (!relayMessages.find(m => m.id === msg.id)) { relayMessages.push(msg); newCount++; newMsgs.push(msg); // Track unread per sender const sender = (msg.sender || '').toLowerCase(); const to = (msg.recipient || msg.to || '*'); const me = (currentUser?.username || '').toLowerCase(); if (sender && sender !== me) { if (to === '*' || to === 'general') { if (relayDmTarget !== null) { relayUnread['__feed__'] = (relayUnread['__feed__'] || 0) + 1; } } else { if (relayDmTarget !== sender) { relayUnread[sender] = (relayUnread[sender] || 0) + 1; } } // Track last message preview relayLastMsg[sender] = { text: msg.text || '', ts: msg.ts_iso || '' }; } } if (msg.ts > relayLastTs) relayLastTs = msg.ts; } if (newCount > 0) { saveRelayUnread(); renderRelayMessages(); renderContacts(); updateRelayBadgeTotal(); // Use notification system instead of raw playRelaySound newMsgs.forEach(m => notifyRelayMessage(m)); } } } catch(e) { console.error('Relay poll error:', e); } } function startRelayPoll() { // Relay polling handled by startBgRelayPoll() — always on } function stopRelayPoll() { // no-op: relay poll always runs } function formatRelayTime(ts_iso) { if (!ts_iso) return ''; try { const d = new Date(ts_iso); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); if (isToday) return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); return d.toLocaleDateString([], {month: 'short', day: 'numeric'}) + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); } catch(e) { return ts_iso; } } function renderRelayMessages() { const container = document.getElementById('relayMessages'); let filtered; if (relayDmTarget && currentUser) { // DM thread: show messages between me and the target const me = currentUser.username.toLowerCase(); const target = relayDmTarget.toLowerCase(); filtered = relayMessages.filter(m => { const s = (m.sender || '').toLowerCase(); const r = (m.recipient || m.to || '*').toLowerCase(); return (s === me && r === target) || (s === target && r === me) || (s.includes(target) && r === me) || (s === me && r.includes(target)) || (s.includes(me) && r.includes(target)) || (s.includes(target) && r.includes(me)); }); } else { // General feed: show broadcasts (to=*) on the general channel filtered = relayMessages.filter(m => { const r = (m.recipient || m.to || '*'); const ch = (m.channel || 'general'); return r === '*' || ch === 'general'; }); } if (!filtered.length) { container.innerHTML = '

No messages yet.

'; return; } let lastDate = ''; container.innerHTML = filtered.map(m => { const info = getIdentity(m.sender); const ch = m.channel || 'general'; const to = m.recipient || m.to || '*'; const toLabel = to === '*' ? '' : ` → ${getIdentity(to).name}`; // Date separator let dateSep = ''; const msgDate = (m.ts_iso || '').slice(0, 10); if (msgDate && msgDate !== lastDate) { lastDate = msgDate; const d = new Date(msgDate); const today = new Date().toISOString().slice(0, 10); const label = msgDate === today ? 'Today' : d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); dateSep = `
${label}
`; } return `${dateSep}
${info.initials}
${escapeHtml(info.name)}
${toLabel ? `${escapeHtml(toLabel)}` : ''} #${ch} ${formatRelayTime(m.ts_iso)}
${escapeHtml(m.text || '')}
`; }).join(''); container.scrollTop = container.scrollHeight; } async function sendRelayMsg() { const input = document.getElementById('relayInput'); const text = input.value.trim(); if (!text) return; const channel = relayDmTarget ? 'dm_' + relayDmTarget : 'general'; const to = relayDmTarget || '*'; try { const resp = await fetch('/api/relay/send', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ text, channel, to, sender: currentUser?.username || 'anonymous' }) }); const data = await resp.json(); if (data.error) { alert('Relay send error: ' + (data.detail || data.error)); } else { // Add message locally so it shows immediately const localMsg = { id: 'local_' + Date.now(), sender: currentUser?.username || 'anonymous', text: text, channel: channel, recipient: to, to: to, ts: Date.now() / 1000, ts_iso: new Date().toISOString(), _local: true }; relayMessages.push(localMsg); renderRelayMessages(); input.value = ''; } } catch(e) { alert('Relay send failed: ' + e.message); } } // Persistent audio context — browsers block AudioContext until first user gesture let _relayAudioCtx = null; document.addEventListener('click', function _initAudio() { if (!_relayAudioCtx) { _relayAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } document.removeEventListener('click', _initAudio); }, { once: true }); function playRelaySound() { if (!notifSettings.sound || notifSettings.soundType === 'none') return; playNotifSoundByType(notifSettings.soundType || 'chime'); } // ── Notification system ────────────────────── // Per-contact notification prefs stored in localStorage // Format: {username: "all"|"toast"|"sound"|"read"|"off", __default__: "all"} let notifPrefs = {}; function loadNotifPrefs() { try { notifPrefs = JSON.parse(localStorage.getItem('relayNotifPrefs') || '{}'); } catch(e) { notifPrefs = {}; } if (!notifPrefs.__default__) notifPrefs.__default__ = 'all'; } function saveNotifPrefs() { localStorage.setItem('relayNotifPrefs', JSON.stringify(notifPrefs)); // Also save to server settings if (typeof saveSettingToServer === 'function') saveSettingToServer('relayNotifPrefs', JSON.stringify(notifPrefs)); } function getNotifPref(username) { return notifPrefs[username] || notifPrefs.__default__ || 'all'; } loadNotifPrefs(); // ── Global notification settings ────────────────────── const NOTIF_DEFAULTS = { enabled: true, sound: true, toast: true, titleFlash: true, browser: true, soundType: 'chime', opacity: 100, duration: 8, volume: 50, position: 'top-left', defaultMode: 'all' }; let notifSettings = {}; function loadNotifSettings() { try { notifSettings = JSON.parse(localStorage.getItem('notifSettings') || '{}'); } catch(e) { notifSettings = {}; } // Fill defaults for (const k in NOTIF_DEFAULTS) { if (notifSettings[k] === undefined) notifSettings[k] = NOTIF_DEFAULTS[k]; } } function saveNotifSettings() { localStorage.setItem('notifSettings', JSON.stringify(notifSettings)); if (typeof saveSettingToServer === 'function') saveSettingToServer('notifSettings', JSON.stringify(notifSettings)); } loadNotifSettings(); // Sync default mode into notifPrefs if (notifSettings.defaultMode) notifPrefs.__default__ = notifSettings.defaultMode; function setNotifSetting(key, val) { if (key === 'opacity' || key === 'duration' || key === 'volume') val = parseInt(val); notifSettings[key] = val; saveNotifSettings(); // Sync default mode to per-contact system if (key === 'defaultMode') { notifPrefs.__default__ = val; saveNotifPrefs(); } // Apply toast container position live if (key === 'position') applyToastPosition(val); } function selectNotifSound(name) { notifSettings.soundType = name; saveNotifSettings(); // Update UI buttons document.querySelectorAll('#notifSoundRow .notif-sound-btn').forEach(b => { b.classList.toggle('active', b.getAttribute('data-sound') === name); }); // Play preview if (name !== 'none') playNotifSoundByType(name); } function setToastPosition(pos) { notifSettings.position = pos; saveNotifSettings(); applyToastPosition(pos); // Update grid UI document.querySelectorAll('#notifPosGrid .notif-pos-cell').forEach(c => { c.classList.toggle('active', c.getAttribute('data-pos') === pos); }); } function applyToastPosition(pos) { const el = document.getElementById('toastContainer'); if (!el) return; // Reset el.style.top = ''; el.style.bottom = ''; el.style.left = ''; el.style.right = ''; el.style.transform = ''; el.style.flexDirection = 'column'; const parts = pos.split('-'); const vert = parts[0]; // top, middle, bottom const horiz = parts[1]; // left, center, right if (vert === 'top') el.style.top = '16px'; else if (vert === 'bottom') { el.style.bottom = '16px'; el.style.flexDirection = 'column-reverse'; } else { el.style.top = '50%'; el.style.transform = 'translateY(-50%)'; } if (horiz === 'left') el.style.left = '16px'; else if (horiz === 'right') el.style.right = '16px'; else { el.style.left = '50%'; el.style.transform = (el.style.transform || '') + ' translateX(-50%)'; } } // Sound presets — different tones (throttled to prevent burst on reconnect) let _lastSoundTime = 0; function playNotifSoundByType(type) { const now = Date.now(); if (now - _lastSoundTime < 500) return; _lastSoundTime = now; try { if (!_relayAudioCtx) _relayAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_relayAudioCtx.state === 'suspended') _relayAudioCtx.resume(); const now = _relayAudioCtx.currentTime; const vol = (notifSettings.volume || 50) / 100 * 0.25; if (type === 'chime') { [880, 1100].forEach((freq, i) => { const osc = _relayAudioCtx.createOscillator(); const gain = _relayAudioCtx.createGain(); osc.connect(gain); gain.connect(_relayAudioCtx.destination); osc.frequency.value = freq; osc.type = 'sine'; gain.gain.setValueAtTime(vol, now + i * 0.12); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.2); osc.start(now + i * 0.12); osc.stop(now + i * 0.12 + 0.2); }); } else if (type === 'ping') { const osc = _relayAudioCtx.createOscillator(); const gain = _relayAudioCtx.createGain(); osc.connect(gain); gain.connect(_relayAudioCtx.destination); osc.frequency.value = 1200; osc.type = 'sine'; gain.gain.setValueAtTime(vol, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); osc.start(now); osc.stop(now + 0.15); } else if (type === 'bell') { [523, 659, 784].forEach((freq, i) => { const osc = _relayAudioCtx.createOscillator(); const gain = _relayAudioCtx.createGain(); osc.connect(gain); gain.connect(_relayAudioCtx.destination); osc.frequency.value = freq; osc.type = 'triangle'; gain.gain.setValueAtTime(vol * 0.8, now + i * 0.1); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3); osc.start(now + i * 0.1); osc.stop(now + i * 0.1 + 0.3); }); } else if (type === 'drop') { const osc = _relayAudioCtx.createOscillator(); const gain = _relayAudioCtx.createGain(); osc.connect(gain); gain.connect(_relayAudioCtx.destination); osc.frequency.setValueAtTime(800, now); osc.frequency.exponentialRampToValueAtTime(200, now + 0.25); osc.type = 'sine'; gain.gain.setValueAtTime(vol, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); osc.start(now); osc.stop(now + 0.3); } else if (type === 'triple') { [660, 880, 1100].forEach((freq, i) => { const osc = _relayAudioCtx.createOscillator(); const gain = _relayAudioCtx.createGain(); osc.connect(gain); gain.connect(_relayAudioCtx.destination); osc.frequency.value = freq; osc.type = 'square'; gain.gain.setValueAtTime(vol * 0.4, now + i * 0.08); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.1); osc.start(now + i * 0.08); osc.stop(now + i * 0.08 + 0.1); }); } } catch(e) { console.warn('Notif sound failed:', e); } } function testNotification() { const fakeMsg = { sender: 'test_user', text: 'This is a test notification preview!', ts_iso: new Date().toISOString(), id: 'test_' + Date.now() }; // Force show regardless of prefs if (notifSettings.sound && notifSettings.soundType !== 'none') playNotifSoundByType(notifSettings.soundType); showRelayToast('test_user', fakeMsg.text, fakeMsg.ts_iso); if (notifSettings.titleFlash && !document.hasFocus()) startTitleFlash(1); if (notifSettings.browser && typeof Notification !== 'undefined' && Notification.permission === 'granted') { try { new Notification('Test User', { body: fakeMsg.text, tag: 'relay-test', silent: true }); } catch(e) {} } } // Sync settings panel UI on cmd panel open function syncNotifSettingsUI() { const s = notifSettings; const el = (id) => document.getElementById(id); if (el('notifMasterToggle')) el('notifMasterToggle').checked = s.enabled !== false; if (el('notifSoundToggle')) el('notifSoundToggle').checked = s.sound !== false; if (el('notifToastToggle')) el('notifToastToggle').checked = s.toast !== false; if (el('notifTitleFlash')) el('notifTitleFlash').checked = s.titleFlash !== false; if (el('notifBrowser')) el('notifBrowser').checked = s.browser !== false; if (el('notifOpacity')) { el('notifOpacity').value = s.opacity || 100; } if (el('notifOpacityVal')) el('notifOpacityVal').textContent = (s.opacity || 100) + '%'; if (el('notifDuration')) { el('notifDuration').value = s.duration || 8; } if (el('notifDurationVal')) el('notifDurationVal').textContent = (s.duration || 8) + 's'; if (el('notifVolume')) { el('notifVolume').value = s.volume || 50; } if (el('notifVolumeVal')) el('notifVolumeVal').textContent = (s.volume || 50) + '%'; if (el('notifDefaultMode')) el('notifDefaultMode').value = s.defaultMode || 'all'; // Sound type buttons document.querySelectorAll('#notifSoundRow .notif-sound-btn').forEach(b => { b.classList.toggle('active', b.getAttribute('data-sound') === (s.soundType || 'chime')); }); // Position grid document.querySelectorAll('#notifPosGrid .notif-pos-cell').forEach(c => { c.classList.toggle('active', c.getAttribute('data-pos') === (s.position || 'top-left')); }); } // Apply saved position on load applyToastPosition(notifSettings.position || 'top-left'); // Toast system function showRelayToast(sender, text, tsIso) { const container = document.getElementById('toastContainer'); if (!container) return; const toast = document.createElement('div'); toast.className = 'relay-toast'; // Set animation direction based on position const pos = notifSettings.position || 'top-left'; const horiz = pos.split('-')[1] || 'left'; const anim = horiz === 'right' ? 'toast-slide-in-right' : horiz === 'center' ? 'toast-slide-in-top' : 'toast-slide-in-left'; toast.style.animationName = anim; const info = getIdentity(sender); const displayName = info.name || sender; const timeStr = formatRelayTime(tsIso); const preview = (text || '').substring(0, 200); toast.innerHTML = `
${displayName}
${preview}
${timeStr}
`; toast.onclick = () => { // Click toast to open that person's DM thread openDmThread(sender.toLowerCase()); dismissToast(toast); }; // Apply opacity from settings toast.style.opacity = (notifSettings.opacity || 100) / 100; container.appendChild(toast); // Auto-dismiss after configured duration const dur = (notifSettings.duration || 8) * 1000; setTimeout(() => dismissToast(toast), dur); // Limit to 5 visible toasts while (container.children.length > 5) { container.removeChild(container.firstChild); } } function dismissToast(toast) { if (!toast || !toast.parentNode) return; toast.classList.add('toast-exit'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); } // Title bar flash — makes the page title flash when there are unread messages let _titleFlashInterval = null; let _originalTitle = document.title; function startTitleFlash(count) { if (_titleFlashInterval) return; // already flashing _originalTitle = document.title; let show = true; _titleFlashInterval = setInterval(() => { document.title = show ? `(${count}) NEW MESSAGE` : _originalTitle; show = !show; }, 1000); } function stopTitleFlash() { if (_titleFlashInterval) { clearInterval(_titleFlashInterval); _titleFlashInterval = null; document.title = _originalTitle; } } // Stop title flash when window gets focus window.addEventListener('focus', stopTitleFlash); // Suppress notification burst on page load — accumulated relay messages from offline // period would all fire sounds/toasts simultaneously, crashing mobile browsers let _notifSuppressUntil = Date.now() + 5000; // Master notification dispatcher — called for each new relay message function notifyRelayMessage(msg) { // Master kill switch if (!notifSettings.enabled) return; // Suppress during initial load cooldown if (Date.now() < _notifSuppressUntil) return; const sender = (msg.sender || '').toLowerCase(); const me = (currentUser && currentUser.username || '').toLowerCase(); if (sender === me) return; // don't notify self const pref = getNotifPref(sender); if (pref === 'off') return; const text = msg.text || ''; const tsIso = msg.ts_iso || ''; // Sound — play for: all, sound (and global sound toggle on) if ((pref === 'all' || pref === 'sound') && notifSettings.sound) { playRelaySound(); } // Toast — show for: all, toast (and global toast toggle on) if ((pref === 'all' || pref === 'toast') && notifSettings.toast) { showRelayToast(sender, text, tsIso); } // Title flash — when page not focused and setting enabled if (notifSettings.titleFlash && !document.hasFocus()) { const total = Object.values(relayUnread).reduce((a, b) => a + b, 0); startTitleFlash(total); } // Browser notification (if permitted and enabled) if (notifSettings.browser && (pref === 'all' || pref === 'toast') && typeof Notification !== 'undefined' && Notification.permission === 'granted') { try { const info = getIdentity(sender); new Notification(info.name || sender, { body: text.substring(0, 100), tag: 'relay-' + sender, silent: true // we already play our own sound }); } catch(e) {} } } // Request browser notification permission on first interaction document.addEventListener('click', function _reqNotif() { if (typeof Notification !== 'undefined' && Notification.permission === 'default') { Notification.requestPermission(); } document.removeEventListener('click', _reqNotif); }, { once: true }); function updateRelayBadgeTotal() { const total = Object.values(relayUnread).reduce((a, b) => a + b, 0); const badge1 = document.getElementById('relayBadge'); const badge2 = document.getElementById('relayPanelBadge'); if (badge1) { badge1.textContent = total; badge1.style.display = total > 0 ? '' : 'none'; } if (badge2) { badge2.textContent = total; badge2.style.display = total > 0 ? '' : 'none'; } } // ── Unified Poll (single 5s timer replaces all individual pollers) ── loadRelayUnread(); updateRelayBadgeTotal(); let _unifiedPollTimer = null; function _processRelayData(data) { if (!data.messages || data.messages.length === 0) return; let newCount = 0; const newMsgs = []; for (const msg of data.messages) { const isDupe = relayMessages.find(m => m.id === msg.id) || relayMessages.find(m => m._local && m.sender === msg.sender && m.text === msg.text && Math.abs(m.ts - msg.ts) < 10); if (isDupe) { const localIdx = relayMessages.findIndex(m => m._local && m.sender === msg.sender && m.text === msg.text); if (localIdx >= 0) relayMessages[localIdx] = msg; } else { relayMessages.push(msg); newCount++; newMsgs.push(msg); const sender = (msg.sender || '').toLowerCase(); const to = (msg.recipient || msg.to || '*'); const me = (currentUser?.username || '').toLowerCase(); if (sender && sender !== me) { if (to === '*' || to === 'general') { if (relayDmTarget !== null) { relayUnread['__feed__'] = (relayUnread['__feed__'] || 0) + 1; } } else { if (relayDmTarget !== sender) { relayUnread[sender] = (relayUnread[sender] || 0) + 1; } } relayLastMsg[sender] = { text: msg.text || '', ts: msg.ts_iso || '' }; } } if (msg.ts > relayLastTs) relayLastTs = msg.ts; } if (newCount > 0) { saveRelayUnread(); renderRelayMessages(); renderContacts(); updateRelayBadgeTotal(); newMsgs.forEach(m => notifyRelayMessage(m)); } } function _processActivityData(activity) { document.querySelectorAll('.session-item').forEach(item => { const sid = item.dataset?.sessionId; if (!sid) return; const info = activity[sid]; let indicator = item.querySelector('.session-activity'); if (info) { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'session-activity'; item.querySelector('.session-info')?.appendChild(indicator); } // Only update innerHTML if status actually changed — prevents animation restart if (indicator.dataset.lastStatus !== info.status) { indicator.dataset.lastStatus = info.status; const status = (info.status || '').toLowerCase(); const icon = status.includes('think') ? '🤔' : status.includes('run') ? '⚙️' : '✍️'; indicator.innerHTML = `${icon} ${info.status}`; } indicator.style.display = ''; } else if (indicator) { indicator.dataset.lastStatus = ''; indicator.style.display = 'none'; } }); } function _updateSidebarActivity(sessionId, status) { const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); if (!item) return; let indicator = item.querySelector('.session-activity'); if (status) { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'session-activity'; item.querySelector('.session-info')?.appendChild(indicator); } if (indicator.dataset.lastStatus !== status) { indicator.dataset.lastStatus = status; const s = status.toLowerCase(); const icon = s.includes('think') ? '🤔' : s.includes('run') ? '⚙️' : '✍️'; indicator.innerHTML = `${icon} ${status}`; } indicator.style.display = ''; } else if (indicator) { indicator.dataset.lastStatus = ''; indicator.style.display = 'none'; } } function _processContactsData(contacts) { contactsData = contacts; syncRelayIdentitiesFromContacts(); // Update online count const onlineCount = contacts.filter(c => c.online).length; const el = document.getElementById('onlineNum'); if (el) el.textContent = onlineCount; // Update presence data for dropdown onlinePresenceData = {}; for (const c of contacts) { if (c.online) { onlinePresenceData[c.username] = { location_type: c.location_type, location_id: c.location_id, }; } } if (onlineDropdownOpen) renderOnlineDropdown(); // Re-render contacts tab if visible const contactsView = document.getElementById('sidebarContactsView'); if (contactsView?.classList.contains('active')) renderContacts(); } async function _unifiedPoll() { try { const resp = await fetch(`/api/poll?relay_since=${relayLastTs}`); if (!resp.ok) return; const data = await resp.json(); if (data.activity) _processActivityData(data.activity); if (data.contacts) _processContactsData(data.contacts); if (data.relay) _processRelayData(data.relay); } catch(e) {} } function startUnifiedPoll() { if (_unifiedPollTimer) return; _unifiedPollTimer = setInterval(_unifiedPoll, 5000); _unifiedPoll(); // immediate first poll } // Legacy alias for any remaining references function startBgRelayPoll() { startUnifiedPoll(); } // ── Message Replay ──────────────────────────── let _playFromHereAbort = false; function cleanTextForSpeech(text) { return (text || '') .replace(/```[\s\S]*?```/g, '... code block omitted ...') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/#{1,6}\s/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\[ARTIFACT[^\]]*\]/g, '... artifact saved ...') .replace(/\[Attached files:[^\]]*\]/g, '... file attached ...') .replace(/\[Files saved to:[^\]]*\]/g, '') .replace(/\n{2,}/g, '. ') .replace(/\n/g, ' ') .trim(); } function speakRaw(text, onEnd) { if (!window.speechSynthesis) return; const clean = cleanTextForSpeech(text); if (!clean) { if (onEnd) onEnd(); return; } const sentences = clean.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [clean]; const chunks = []; let current = ''; for (const s of sentences) { if ((current + s).length > 200) { if (current) chunks.push(current.trim()); current = s; } else { current += s; } } if (current) chunks.push(current.trim()); speechSynthesis.cancel(); chunks.forEach((chunk, i) => { const utter = new SpeechSynthesisUtterance(chunk); utter.rate = 1.1; if (i === chunks.length - 1 && onEnd) { utter.onend = onEnd; } speechSynthesis.speak(utter); }); } function activateTTS() { if (!ttsEnabled) { ttsEnabled = true; const btn = document.getElementById('ttsBtn'); btn.classList.add('active'); btn.innerHTML = '🔊'; btn.title = 'Auto-read ON — click to mute'; } } function stopAllPlayback() { _playFromHereAbort = true; _stopCoquiPlayback(); if (window.speechSynthesis) speechSynthesis.cancel(); // Reset all playing states document.querySelectorAll('.msg-action-btn.playing').forEach(b => b.classList.remove('playing')); document.querySelectorAll('.message-actions.playing').forEach(a => a.classList.remove('playing')); // Reset any stop buttons back to play document.querySelectorAll('.msg-action-btn[data-mode="stop"]').forEach(b => { b.dataset.mode = 'play'; b.innerHTML = '▶ Play from here'; b.title = 'Read all messages from here to end'; }); // Reset doc TTS if playing if (docTtsPlaying) { docTtsPlaying = false; const btn = document.getElementById('docTtsBtn'); if (btn) { btn.innerHTML = '▶'; btn.classList.remove('playing'); btn.title = 'Read document aloud'; } } } function copyMessage(btn) { const msgEl = btn.closest('.message'); const raw = msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.innerText || ''; navigator.clipboard.writeText(raw).then(() => { const orig = btn.innerHTML; btn.innerHTML = '✓ Copied'; setTimeout(() => { btn.innerHTML = orig; }, 1500); }).catch(() => { // Fallback for non-HTTPS const ta = document.createElement('textarea'); ta.value = raw; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); const orig = btn.innerHTML; btn.innerHTML = '✓ Copied'; setTimeout(() => { btn.innerHTML = orig; }, 1500); }); } async function _getServerTotal() { try { const r = await fetch(`/api/sessions/${currentSessionId}/stats`); const s = await r.json(); return s.total_messages; } catch(e) { return _totalMsgCount || 0; } } async function deleteMessage(btn) { const msgEl = btn.closest('.message'); const serverIdx = parseInt(msgEl.dataset.serverIndex); if (isNaN(serverIdx)) { alert('Message missing server index — hard refresh and try again'); return; } const role = msgEl.classList.contains('user-message') ? 'user' : 'assistant'; const preview = (msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.innerText || '').substring(0, 80); if (!confirm(`Delete this ${role} message?\n\n"${preview}..."`)) return; const pw = prompt('Confirm your login password to delete:'); if (!pw) return; const sid = currentSessionId; fetch(`/api/sessions/${sid}/messages/${serverIdx}`, { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ admin_password: pw }) }).then(async r => { if (!r.ok) { let msg = r.statusText; try { const e = await r.json(); msg = e.detail || msg; } catch(_){} throw new Error(msg); } return r.json(); }).then(data => { msgEl.remove(); _totalMsgCount = data.remaining; _showUndoDeleteToast(sid, pw); }).catch(e => alert('Delete failed: ' + (e.message || String(e)))); } function _showUndoDeleteToast(sessionId, adminPw) { const existing = document.getElementById('undoDeleteToast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.id = 'undoDeleteToast'; toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 20px;border-radius:8px;z-index:10000;display:flex;align-items:center;gap:12px;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.3);'; toast.innerHTML = 'Message deleted '; document.body.appendChild(toast); const undoBtn = document.getElementById('undoDeleteBtn'); let dismissed = false; undoBtn.onclick = () => { if (dismissed) return; dismissed = true; fetch(`/api/sessions/${sessionId}/messages/restore`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ admin_password: adminPw }) }).then(async r => { if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } return r.json(); }).then(data => { toast.remove(); loadSession(sessionId); }).catch(e => { alert('Restore failed: ' + e.message); dismissed = false; }); }; setTimeout(() => { if (!dismissed) toast.remove(); }, 15000); } async function splitAtMessage(btn) { const msgEl = btn.closest('.message'); const serverIdx = parseInt(msgEl.dataset.serverIndex); if (isNaN(serverIdx)) { alert('Message missing server index — hard refresh and try again'); return; } const serverTotal = await _getServerTotal(); const preview = (msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.innerText || '').substring(0, 80); if (!confirm(`Split chat at this message?\n\n"${preview}..."\n\nThis copies messages 1-${serverIdx + 1} of ${serverTotal} into a new session. Original stays unchanged.`)) return; const pw = prompt('Confirm your login password to split:'); if (!pw) return; fetch(`/api/sessions/${currentSessionId}/split/${serverIdx}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ admin_password: pw }) }).then(async r => { if (!r.ok) { let msg = r.statusText; try { const e = await r.json(); msg = e.detail || msg; } catch(_){} throw new Error(msg); } return r.json(); }).then(data => { alert(`Split created: "${data.new_session_name}" with ${data.messages_copied} messages`); loadSessions(); }).catch(e => alert('Split failed: ' + (e.message || String(e)))); } let _selectMode = false; function toggleSelectMode() { _selectMode = !_selectMode; const chat = document.getElementById('chatArea'); const btn = document.getElementById('selectModeBtn'); const bar = document.getElementById('bulkDeleteBar'); if (_selectMode) { chat.classList.add('select-mode'); btn.classList.add('active'); bar.style.display = 'flex'; } else { chat.classList.remove('select-mode'); btn.classList.remove('active'); bar.style.display = 'none'; chat.querySelectorAll('.message.selected').forEach(m => m.classList.remove('selected')); } updateBulkDeleteBar(); } function updateBulkDeleteBar() { const count = document.querySelectorAll('#chatArea .message.selected').length; document.getElementById('bulkDeleteCount').textContent = count + ' selected'; } function selectAllMessages() { document.querySelectorAll('#chatArea .message').forEach(m => m.classList.add('selected')); updateBulkDeleteBar(); } async function bulkDeleteSelected() { const selected = Array.from(document.querySelectorAll('#chatArea .message.selected')); if (!selected.length) { alert('No messages selected'); return; } if (!confirm(`Delete ${selected.length} messages? This cannot be undone.`)) return; const pw = prompt('Confirm your login password to delete:'); if (!pw) return; const indices = selected.map(m => parseInt(m.dataset.serverIndex)).filter(i => !isNaN(i)); fetch(`/api/sessions/${currentSessionId}/messages/bulk`, { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ admin_password: pw, indices: indices }) }).then(async r => { if (!r.ok) { let msg = r.statusText; try { const e = await r.json(); msg = e.detail || msg; } catch(_){} throw new Error(msg); } return r.json(); }).then(data => { selected.forEach(m => m.remove()); _totalMsgCount = data.remaining; toggleSelectMode(); }).catch(e => alert('Delete failed: ' + (e.message || String(e)))); } async function bulkMoveSelected() { const selected = Array.from(document.querySelectorAll('#chatArea .message.selected')); if (!selected.length) { alert('No messages selected'); return; } // Fetch sessions list for picker const sessions = await fetch('/api/sessions').then(r => r.json()); const otherSessions = sessions.filter(s => s.id !== currentSessionId); if (!otherSessions.length) { alert('No other sessions to move to'); return; } // Build a simple picker dialog const picker = document.createElement('div'); picker.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10000;'; const box = document.createElement('div'); box.style.cssText = 'background:#1e1e2e;border-radius:12px;padding:24px;max-width:500px;width:90%;max-height:70vh;overflow-y:auto;color:#cdd6f4;'; box.innerHTML = `

Move ${selected.length} messages to:

`; otherSessions.forEach(s => { const btn = document.createElement('button'); btn.style.cssText = 'display:block;width:100%;text-align:left;padding:10px 14px;margin:4px 0;background:#313244;border:1px solid #45475a;border-radius:8px;color:#cdd6f4;cursor:pointer;font-size:14px;'; btn.textContent = (s.name || s.id.slice(0,8)) + ' (' + (s.message_count || 0) + ' msgs)'; btn.onmouseover = () => btn.style.borderColor = '#f5c2e7'; btn.onmouseout = () => btn.style.borderColor = '#45475a'; btn.onclick = async () => { const pw = prompt('Confirm password:'); if (!pw) return; const indices = selected.map(m => parseInt(m.dataset.serverIndex)).filter(i => !isNaN(i)); try { const r = await fetch(`/api/sessions/${currentSessionId}/messages/move`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ admin_password: pw, indices, target_session_id: s.id }) }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const data = await r.json(); selected.forEach(m => m.remove()); _totalMsgCount = data.source_remaining; picker.remove(); toggleSelectMode(); alert(`Moved ${data.moved_count} messages to "${s.name || s.id.slice(0,8)}"`); } catch(e) { alert('Move failed: ' + e.message); } }; box.appendChild(btn); }); const cancelBtn = document.createElement('button'); cancelBtn.style.cssText = 'margin-top:12px;padding:8px 20px;background:#45475a;border:none;border-radius:8px;color:#cdd6f4;cursor:pointer;'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => picker.remove(); box.appendChild(cancelBtn); picker.appendChild(box); picker.onclick = (e) => { if (e.target === picker) picker.remove(); }; document.body.appendChild(picker); } function replayMessage(btn) { stopAllPlayback(); activateTTS(); const msgEl = btn.closest('.message'); const text = msgEl.dataset.rawContent; btn.classList.add('playing'); btn.closest('.message-actions').classList.add('playing'); speakRaw(text, () => { btn.classList.remove('playing'); btn.closest('.message-actions')?.classList.remove('playing'); }); } function playFromHere(btn) { // If already playing, act as stop if (btn.dataset.mode === 'stop') { stopAllPlayback(); return; } stopAllPlayback(); _playFromHereAbort = false; activateTTS(); // Switch button to stop mode btn.dataset.mode = 'stop'; btn.innerHTML = '■ Stop'; btn.title = 'Stop playback'; btn.classList.add('playing'); btn.closest('.message-actions').classList.add('playing'); const msgEl = btn.closest('.message'); const allMessages = Array.from(document.querySelectorAll('#chatArea .message')); const startIdx = allMessages.indexOf(msgEl); if (startIdx === -1) return; const activeBtn = btn; // remember which button started it let idx = startIdx; function playNext() { if (_playFromHereAbort || idx >= allMessages.length) { // Reset the button that started playback activeBtn.dataset.mode = 'play'; activeBtn.innerHTML = '▶ Play from here'; activeBtn.title = 'Read all messages from here to end'; activeBtn.classList.remove('playing'); document.querySelectorAll('.message-actions.playing').forEach(a => a.classList.remove('playing')); return; } const msg = allMessages[idx]; const text = msg.dataset.rawContent; const role = msg.classList.contains('user') ? `${currentUser?.display_name || 'You'} said: ` : ''; const actions = msg.querySelector('.message-actions'); // Highlight current message document.querySelectorAll('.message-actions.playing').forEach(a => { if (a !== activeBtn.closest('.message-actions')) a.classList.remove('playing'); }); if (actions) actions.classList.add('playing'); // Scroll to current message msg.scrollIntoView({ behavior: 'smooth', block: 'center' }); idx++; speakRaw(role + text, playNext); } playNext(); } // ── Lightbox ───────────────────────────────── function openLightbox(url, filename) { const overlay = document.getElementById('lightboxOverlay'); document.getElementById('lightboxImg').src = url; const dl = document.getElementById('lightboxDownload'); dl.href = url; dl.download = filename; overlay.classList.add('active'); } function closeLightbox(e) { // If called from overlay click, only close if clicking the backdrop itself if (e && e.target && e.target !== document.getElementById('lightboxOverlay')) return; document.getElementById('lightboxOverlay').classList.remove('active'); } // Also close on Escape document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeLightbox(); closeCmdPanel(); } }); // ── Drag & Drop ────────────────────────────── document.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); }); document.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.files.length) { Array.from(e.dataTransfer.files).forEach(f => pendingFiles.push(f)); renderAttachedFiles(); } }); // ── Clipboard Paste (images/media) ────────────── document.getElementById('userInput').addEventListener('paste', e => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; for (const item of items) { if (item.kind === 'file') { e.preventDefault(); const file = item.getAsFile(); if (file) { // Give pasted images a readable name with timestamp if (file.name === 'image.png' || !file.name || file.name === 'blob') { const ext = file.type.split('/')[1] || 'png'; const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const named = new File([file], `pasted-${ts}.${ext}`, { type: file.type }); pendingFiles.push(named); } else { pendingFiles.push(file); } renderAttachedFiles(); } } } }); // ── Message Editing ───────────────────────── async function editMessage(btn) { const msgEl = btn.closest('.message'); const msgIndex = parseInt(msgEl.dataset.msgIndex); const content = msgEl.dataset.rawContent || ''; const contentEl = msgEl.querySelector('.message-content'); const actionsEl = msgEl.querySelector('.message-actions'); // Replace content with editable textarea const textarea = document.createElement('textarea'); textarea.value = content; textarea.style.cssText = 'width:100%;min-height:60px;max-height:200px;background:var(--surface);border:1px solid var(--accent);border-radius:8px;padding:10px;color:var(--text);font-family:inherit;font-size:14px;resize:vertical;outline:none;'; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;margin-top:6px;'; btnRow.innerHTML = ` `; contentEl.innerHTML = ''; contentEl.appendChild(textarea); contentEl.appendChild(btnRow); actionsEl.style.display = 'none'; textarea.focus(); } function cancelEdit(btn) { // Just reload the session to restore original state if (currentSessionId) switchSession(currentSessionId); } async function submitEdit(btn, msgIndex) { const msgEl = btn.closest('.message'); const textarea = msgEl.querySelector('textarea'); const newText = textarea.value.trim(); if (!newText) return; btn.textContent = 'Creating branch...'; btn.disabled = true; try { const resp = await fetch(`/api/sessions/${currentSessionId}/branch`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ msg_index: msgIndex, text: newText }) }); const data = await resp.json(); if (data.ok) { // Switch to the new branched session and send the edited message await switchSession(data.session_id); // Set the edited text as input and send it document.getElementById('userInput').value = newText; sendMessage(); } } catch(e) { alert('Branch failed: ' + e.message); btn.textContent = 'Send as branch'; btn.disabled = false; } } // ── Export ────────────────────────────────── async function exportSession(format) { if (!currentSessionId) { alert('No session selected'); return; } const resp = await fetch(`/api/sessions/${currentSessionId}`); const session = await resp.json(); const messages = session.messages || []; if (format === 'md') { let md = `# Chat Export\n\nSession: ${currentSessionId}\nExported: ${new Date().toLocaleString()}\n\n---\n\n`; messages.forEach(msg => { const role = msg.role === 'user' ? `**${currentUser?.display_name || 'You'}**` : '**Claude**'; const ts = msg.timestamp ? ` (${new Date(msg.timestamp).toLocaleString()})` : ''; md += `### ${role}${ts}\n\n${msg.content || ''}\n\n---\n\n`; }); downloadText(md, `chat-${currentSessionId.substring(0,8)}.md`, 'text/markdown'); } else if (format === 'html') { let html = `Chat Export

Chat Export

Session: ${currentSessionId}


`; messages.forEach(msg => { const role = msg.role === 'user' ? (currentUser?.display_name || 'You') : 'Claude'; html += `

${role}

${marked.parse(msg.content || '')}
`; }); html += ''; downloadText(html, `chat-${currentSessionId.substring(0,8)}.html`, 'text/html'); } } function downloadText(content, filename, type) { const blob = new Blob([content], { type }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } // ── Search ────────────────────────────────── let searchResults = []; let searchIdx = -1; function toggleSearch() { const bar = document.getElementById('searchBar'); const active = bar.classList.toggle('active'); if (active) { document.getElementById('searchInput').focus(); } else { clearSearch(); } } function doSearch(query) { clearSearchHighlights(); searchResults = []; searchIdx = -1; if (!query || query.length < 2) { document.getElementById('searchCount').textContent = ''; return; } const q = query.toLowerCase(); document.querySelectorAll('#chatArea .message').forEach(msg => { const text = (msg.dataset.rawContent || msg.textContent || '').toLowerCase(); if (text.includes(q)) { msg.classList.add('search-highlight'); searchResults.push(msg); } }); document.getElementById('searchCount').textContent = searchResults.length ? `${searchResults.length} found` : 'No results'; if (searchResults.length) searchNav(1); } function searchNav(dir) { if (!searchResults.length) return; searchIdx = (searchIdx + dir + searchResults.length) % searchResults.length; const target = searchResults[searchIdx]; const container = document.getElementById('chatArea'); const targetTop = target.offsetTop - container.offsetTop; const centerOffset = targetTop - (container.clientHeight / 2) + (target.offsetHeight / 2); container.scrollTo({ top: centerOffset, behavior: 'smooth' }); document.getElementById('searchCount').textContent = `${searchIdx + 1} / ${searchResults.length}`; } function clearSearchHighlights() { document.querySelectorAll('.search-highlight').forEach(el => el.classList.remove('search-highlight')); } function clearSearch() { clearSearchHighlights(); searchResults = []; searchIdx = -1; document.getElementById('searchInput').value = ''; document.getElementById('searchCount').textContent = ''; } // Ctrl+F shortcut to open search document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); const bar = document.getElementById('searchBar'); if (!bar.classList.contains('active')) toggleSearch(); else document.getElementById('searchInput').focus(); } }); // ── Theme ─────────────────────────────────── function toggleTheme() { const html = document.documentElement; const current = html.getAttribute('data-theme'); const next = current === 'light' ? 'dark' : 'light'; html.setAttribute('data-theme', next); saveSettingToServer('harness-theme', next); // Update command panel theme toggle if open const cmdIcon = document.getElementById('cmdThemeIcon'); const cmdLabel = document.getElementById('cmdThemeLabel'); const cmdCheck = document.getElementById('cmdTheme'); const isDark = next !== 'light'; if (cmdIcon) cmdIcon.innerHTML = isDark ? '🌙' : '☀'; if (cmdLabel) cmdLabel.textContent = isDark ? 'Dark Mode' : 'Light Mode'; if (cmdCheck) cmdCheck.checked = isDark; // Switch highlight.js theme const hlLink = document.querySelector('link[href*="highlight"]'); if (hlLink) hlLink.href = next === 'light' ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css' : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css'; } // Command Panel function openCmdPanel() { const overlay = document.getElementById('cmdPanelOverlay'); overlay.classList.add('open'); // Sync toggle states document.getElementById('cmdPureMode').checked = pureMode; document.getElementById('cmdThinking').checked = showThinking; document.getElementById('cmdTTS').checked = ttsEnabled; // Theme const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; document.getElementById('cmdTheme').checked = isDark; document.getElementById('cmdThemeIcon').innerHTML = isDark ? '🌙' : '☀'; document.getElementById('cmdThemeLabel').textContent = isDark ? 'Dark Mode' : 'Light Mode'; // Model selector sync const cmdModel = document.getElementById('cmdModelSelect'); const mainModel = document.getElementById('modelSelect'); cmdModel.innerHTML = mainModel.innerHTML; cmdModel.value = mainModel.value; // Memory count const memCount = document.getElementById('memSettingCount'); const cmdMem = document.getElementById('cmdMemCount'); if (memCount && cmdMem) cmdMem.textContent = memCount.textContent; // Sync color pickers syncColorPickers(); // Sync notification settings UI syncNotifSettingsUI(); // Claude account status checkClaudeAcctStatus(); } function closeCmdPanel() { document.getElementById('cmdPanelOverlay').classList.remove('open'); } // ── Auth Header Helper ────────────────────────────── function authHeader() { if (currentUser?.token) return {'Authorization': 'Bearer ' + currentUser.token}; return {}; } // ── Claude Account (OAuth) ────────────────────────── let _claudeAcctPollTimer = null; async function checkClaudeAcctStatus() { try { const resp = await fetch('/api/auth/claude-status', {headers: authHeader()}); if (!resp.ok) return; const data = await resp.json(); const label = document.getElementById('claudeAcctLabel'); const btn = document.getElementById('claudeAcctBtn'); const detail = document.getElementById('claudeAcctDetail'); if (!label) return; if (data.has_credentials) { label.textContent = 'Connected (' + data.type + ')'; label.style.color = 'var(--accent)'; btn.textContent = 'Disconnect'; btn.style.display = ''; btn.dataset.action = 'disconnect'; detail.style.display = 'none'; if (_claudeAcctPollTimer) { clearInterval(_claudeAcctPollTimer); _claudeAcctPollTimer = null; } } else { label.textContent = 'Not connected'; label.style.color = 'var(--text-dim)'; btn.textContent = 'Connect'; btn.style.display = ''; btn.dataset.action = 'connect'; detail.innerHTML = 'Connect your Anthropic account to use Claude through this harness.'; detail.style.display = ''; } } catch(e) { console.warn('Claude acct check failed:', e); } } async function handleClaudeAcctAction() { const btn = document.getElementById('claudeAcctBtn'); const action = btn.dataset.action; if (action === 'disconnect') { if (!confirm('Disconnect your Claude account? You will need to re-authenticate to use Claude.')) return; await fetch('/api/auth/claude-disconnect', {method:'POST', headers: authHeader()}); checkClaudeAcctStatus(); return; } // Start OAuth login btn.textContent = 'Starting...'; btn.disabled = true; try { const resp = await fetch('/api/auth/claude-login', {method:'POST', headers: authHeader()}); const data = await resp.json(); if (data.ok && data.url) { window.open(data.url, '_blank'); const detail = document.getElementById('claudeAcctDetail'); detail.innerHTML = 'A login page opened in a new tab. Sign in with your Anthropic account, then come back here.
Waiting for authentication...'; detail.style.display = ''; btn.textContent = 'Waiting...'; // Poll for completion _claudeAcctPollTimer = setInterval(async () => { try { const sr = await fetch('/api/auth/claude-login-status', {headers: authHeader()}); const sd = await sr.json(); if (sd.completed) { clearInterval(_claudeAcctPollTimer); _claudeAcctPollTimer = null; checkClaudeAcctStatus(); showRelayToast('Claude account connected!', 'System'); } else if (sd.expired) { clearInterval(_claudeAcctPollTimer); _claudeAcctPollTimer = null; detail.innerHTML = 'Login timed out. Try again.'; btn.textContent = 'Connect'; btn.disabled = false; } } catch(e) {} }, 3000); } else { const detail = document.getElementById('claudeAcctDetail'); detail.innerHTML = '' + (data.error || 'Failed to start login') + ''; detail.style.display = ''; btn.textContent = 'Retry'; btn.disabled = false; } } catch(e) { btn.textContent = 'Connect'; btn.disabled = false; } } async function togglePureModeFromPanel(checked) { pureMode = checked; saveSettingToServer('userDefault_pureMode', checked); if (currentSessionId) { await fetch(`/api/sessions/${currentSessionId}/pure-mode`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({pure_mode: pureMode}) }); } } async function toggleThinkingFromPanel(checked) { showThinking = checked; localStorage.setItem('userDefault_showThinking', checked); saveSettingToServer('userDefault_showThinking', checked); if (currentSessionId) { await fetch(`/api/sessions/${currentSessionId}/show-thinking`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({show_thinking: showThinking}) }); } } function toggleTTSFromPanel(checked) { if (checked !== ttsEnabled) toggleTTS(); saveSettingToServer('userDefault_ttsEnabled', checked); } function toggleThemeFromPanel(checked) { // checked = true means dark mode ON const current = document.documentElement.getAttribute('data-theme'); const wantDark = checked; const isDark = current !== 'light'; if (wantDark !== isDark) toggleTheme(); document.getElementById('cmdThemeIcon').innerHTML = wantDark ? '🌙' : '☀'; document.getElementById('cmdThemeLabel').textContent = wantDark ? 'Dark Mode' : 'Light Mode'; } let pureMode = false; async function togglePureMode() { pureMode = !pureMode; saveSettingToServer('userDefault_pureMode', pureMode); if (currentSessionId) { await fetch(`/api/sessions/${currentSessionId}/pure-mode`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({pure_mode: pureMode}) }); } } async function syncPureModeToggle() { if (!currentSessionId) return; try { const r = await fetch(`/api/sessions/${currentSessionId}/pure-mode`, {headers: authHeader()}); const d = await r.json(); pureMode = d.pure_mode; } catch(e) {} } let showThinking = true; async function toggleThinking() { showThinking = !showThinking; localStorage.setItem('userDefault_showThinking', showThinking); saveSettingToServer('userDefault_showThinking', showThinking); if (currentSessionId) { await fetch(`/api/sessions/${currentSessionId}/show-thinking`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({show_thinking: showThinking}) }); } } async function syncThinkingToggle() { if (!currentSessionId) return; try { const r = await fetch(`/api/sessions/${currentSessionId}/show-thinking`, {headers: authHeader()}); if (r.ok) { const d = await r.json(); showThinking = d.show_thinking; } else { console.warn('[Thinking] sync failed:', r.status); } } catch(e) { console.warn('[Thinking] sync error:', e); } // Fallback: if session says false but user default says true, use user default const lsDefault = localStorage.getItem('userDefault_showThinking'); if (lsDefault !== null && lsDefault !== 'false' && !showThinking) { // User wants thinking on but session has it off — fix the session showThinking = true; if (currentSessionId) { fetch(`/api/sessions/${currentSessionId}/show-thinking`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({show_thinking: true}) }).catch(()=>{}); } } } function closeSettings() { closeCmdPanel(); } // ── Coding Mode ──────────────────────────────── let codingMode = false; let hudPollTimer = null; async function setCodingMode(active) { codingMode = active; // Always persist to localStorage immediately (sync, never fails) localStorage.setItem('userDefault_codingMode', String(active)); updateCodingModeUI(); if (!currentSessionId) return; try { await fetch(`/api/sessions/${currentSessionId}/coding-mode`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({coding_mode: active}) }); } catch(e) { console.warn('[CodingMode] POST failed:', e); } saveSettingToServer('userDefault_codingMode', active); } function toggleCodingMode() { setCodingMode(!codingMode); } function toggleCodingModeFromPanel(checked) { setCodingMode(checked); } async function syncCodingMode() { if (!currentSessionId) return; // Primary: fetch session-level state from server try { const r = await fetch(`/api/sessions/${currentSessionId}/coding-mode`, {headers: authHeader()}); if (r.ok) { const d = await r.json(); codingMode = d.coding_mode; } } catch(e) { console.warn('[CodingMode] GET failed:', e); } // Fallback: if server says off, check localStorage user default if (!codingMode) { const userDefault = localStorage.getItem('userDefault_codingMode'); if (userDefault === 'true') { setCodingMode(true); return; } } updateCodingModeUI(); } function updateCodingModeUI() { const hud = document.getElementById('codingHud'); const checkbox = document.getElementById('cmdCodingMode'); const focusRow = document.getElementById('cmdFocusRow'); const vpRow = document.getElementById('cmdViewportRow'); if (hud) hud.style.display = codingMode ? 'block' : 'none'; if (checkbox) checkbox.checked = codingMode; if (focusRow) focusRow.style.display = codingMode ? 'flex' : 'none'; if (vpRow) vpRow.style.display = codingMode ? 'flex' : 'none'; if (codingMode) { startHudPoll(); pollHudNow(); refreshViewportCount(); } else { stopHudPoll(); } } // ── Code Viewport ──────────────────────────────── let _viewportFiles = []; async function refreshViewportCount() { if (!currentSessionId) return; try { const r = await fetch(`/api/sessions/${currentSessionId}/viewport`, {headers: authHeader()}); const d = await r.json(); _viewportFiles = d.working_files || []; const countEl = document.getElementById('viewportCount'); if (countEl) countEl.textContent = `${_viewportFiles.length} file${_viewportFiles.length !== 1 ? 's' : ''}`; } catch(e) {} } function openViewportPanel() { document.getElementById('viewportPanel').style.display = 'block'; document.getElementById('viewportOverlay').style.display = 'block'; renderViewportList(); } function closeViewportPanel() { document.getElementById('viewportPanel').style.display = 'none'; document.getElementById('viewportOverlay').style.display = 'none'; } async function renderViewportList() { if (!currentSessionId) return; try { const r = await fetch(`/api/sessions/${currentSessionId}/viewport`, {headers: authHeader()}); const d = await r.json(); _viewportFiles = d.working_files || []; } catch(e) { _viewportFiles = []; } const container = document.getElementById('viewportFileList'); if (!_viewportFiles.length) { container.innerHTML = '
No files in viewport. Add files to keep them in context during coding.
'; return; } container.innerHTML = _viewportFiles.map((f, i) => `
${f} ×
`).join(''); const countEl = document.getElementById('viewportCount'); if (countEl) countEl.textContent = `${_viewportFiles.length} file${_viewportFiles.length !== 1 ? 's' : ''}`; } async function addViewportFile() { const input = document.getElementById('viewportFileInput'); const val = input.value.trim(); if (!val || !currentSessionId) return; try { await fetch(`/api/sessions/${currentSessionId}/viewport`, { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({file: val}) }); input.value = ''; renderViewportList(); } catch(e) {} } async function removeViewportFile(index) { if (!currentSessionId) return; try { await fetch(`/api/sessions/${currentSessionId}/viewport`, { method: 'DELETE', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({index: index}) }); renderViewportList(); } catch(e) {} } // HUD polling removed — HUD is context-injection only, not a browser UI element function startHudPoll() {} function stopHudPoll() {} async function pollHudNow() {} function renderHud(hud) { const gpu = hud.gpu || {}; const train = hud.training || {}; const git = hud.git || {}; const vault = hud.vault || {}; const procs = hud.procs || {}; const focus = hud.focus || {}; // GPU — color by temp const gpuEl = document.getElementById('hudGpu'); if (gpuEl) { gpuEl.textContent = 'GPU:' + (gpu.text || 'N/A'); const temp = gpu.temp || 0; gpuEl.style.color = temp > 82 ? '#ff4757' : temp > 70 ? '#eccc68' : '#00d4ff'; } // Training const trainEl = document.getElementById('hudTrain'); if (trainEl) { if (train.active) { trainEl.textContent = 'Train:' + (train.detail || train.text || 'active'); trainEl.style.color = (train.detail && train.detail.toLowerCase().includes('nan')) ? '#ff4757' : '#ff6b35'; } else { trainEl.textContent = 'Train:none'; trainEl.style.color = '#5a6785'; } } // Git const gitEl = document.getElementById('hudGit'); if (gitEl) { gitEl.textContent = 'Git:' + (git.text || 'N/A'); gitEl.style.color = (git.staged > 0) ? '#ff4757' : (git.modified > 0) ? '#eccc68' : '#7bed9f'; } // Vault const vaultEl = document.getElementById('hudVault'); if (vaultEl) { vaultEl.textContent = 'Vault:' + (vault.text || '0'); vaultEl.style.color = vault.count > 0 ? '#eccc68' : '#ff4757'; } // Procs const procsEl = document.getElementById('hudProcs'); if (procsEl) { procsEl.textContent = 'Procs:' + (procs.text || 'none'); procsEl.style.color = procs.locked > 0 ? '#ff4757' : '#5a6785'; } // Focus const focusEl = document.getElementById('hudFocus'); if (focusEl) { const fText = focus.text || 'none set'; focusEl.textContent = 'Focus:' + (fText.length > 40 ? fText.slice(0,37) + '...' : fText); } } // ── Focus Editor ─────────────────────────────── function openFocusEditor() { document.getElementById('focusModal').classList.add('active'); loadFocusData(); } function closeFocusEditor() { document.getElementById('focusModal').classList.remove('active'); } async function loadFocusData() { try { const r = await fetch('/api/coding-focus', {headers: authHeader()}); const data = await r.json(); document.getElementById('focusText').value = data.focus || ''; document.getElementById('focusKeyFiles').value = (data.key_files || []).join('\n'); document.getElementById('focusDecisions').value = (data.decisions || []).join('\n'); document.getElementById('focusWarnings').value = (data.warnings || []).join('\n'); if (data.last_updated) { document.getElementById('focusLastUpdated').textContent = 'Last updated: ' + new Date(data.last_updated).toLocaleString(); } } catch(e) {} } async function saveFocusEditor() { const focus = document.getElementById('focusText').value.trim(); const keyFiles = document.getElementById('focusKeyFiles').value.trim().split('\n').filter(l => l.trim()); const decisions = document.getElementById('focusDecisions').value.trim().split('\n').filter(l => l.trim()); const warnings = document.getElementById('focusWarnings').value.trim().split('\n').filter(l => l.trim()); try { const r = await fetch('/api/coding-focus', { method: 'POST', headers: {'Content-Type': 'application/json', ...authHeader()}, body: JSON.stringify({focus, key_files: keyFiles, decisions, warnings}) }); const d = await r.json(); if (d.ok) { document.getElementById('focusStatus').textContent = 'Saved!'; setTimeout(() => document.getElementById('focusStatus').textContent = '', 2000); // Refresh HUD pollHudNow(); } } catch(e) { document.getElementById('focusStatus').textContent = 'Error saving'; } } // ── Color Scheme ─────────────────────────────── const COLOR_PRESETS = { default: { '--bg': '#1a1a2e', '--bg-secondary': '#16213e', '--bg-tertiary': '#0f3460', '--surface': '#1e2746', '--surface-hover': '#263055', '--text': '#e0e0e0', '--text-muted': '#8892b0', '--text-dim': '#5a6785', '--accent': '#64ffda', '--accent-dim': '#3d997f', '--user-bg': '#1e3a5f', '--assistant-bg': '#1e2746', '--border': '#2d3555', '--scrollbar': '#2d3555', '--scrollbar-hover': '#3d4565' }, midnight: { '--bg': '#0d0d1a', '--bg-secondary': '#111128', '--bg-tertiary': '#1a1a3e', '--surface': '#161630', '--surface-hover': '#1e1e40', '--text': '#d0d0e8', '--text-muted': '#7070a0', '--text-dim': '#4a4a70', '--accent': '#8b5cf6', '--accent-dim': '#6d43c9', '--user-bg': '#1a1540', '--assistant-bg': '#161630', '--border': '#252550', '--scrollbar': '#252550', '--scrollbar-hover': '#353570' }, forest: { '--bg': '#0f1a14', '--bg-secondary': '#132218', '--bg-tertiary': '#1a3324', '--surface': '#162a1c', '--surface-hover': '#1e3825', '--text': '#d4e8d8', '--text-muted': '#6a9a74', '--text-dim': '#4a7054', '--accent': '#4ade80', '--accent-dim': '#2d9e55', '--user-bg': '#1a3328', '--assistant-bg': '#162a1c', '--border': '#2a4a32', '--scrollbar': '#2a4a32', '--scrollbar-hover': '#3a5a42' }, ocean: { '--bg': '#0a1628', '--bg-secondary': '#0e1e38', '--bg-tertiary': '#142e4e', '--surface': '#122440', '--surface-hover': '#183050', '--text': '#c8dce8', '--text-muted': '#5a8aaa', '--text-dim': '#3a6a8a', '--accent': '#38bdf8', '--accent-dim': '#2090c8', '--user-bg': '#142e50', '--assistant-bg': '#122440', '--border': '#1e3a58', '--scrollbar': '#1e3a58', '--scrollbar-hover': '#2e4a68' }, ember: { '--bg': '#1a1210', '--bg-secondary': '#241814', '--bg-tertiary': '#3a2018', '--surface': '#2a1c16', '--surface-hover': '#362420', '--text': '#e8d8d0', '--text-muted': '#a07060', '--text-dim': '#705040', '--accent': '#f97316', '--accent-dim': '#c85a10', '--user-bg': '#3a2418', '--assistant-bg': '#2a1c16', '--border': '#4a3028', '--scrollbar': '#4a3028', '--scrollbar-hover': '#5a4038' }, lavender: { '--bg': '#18141e', '--bg-secondary': '#201a2a', '--bg-tertiary': '#2e2440', '--surface': '#241e30', '--surface-hover': '#302840', '--text': '#e0d8e8', '--text-muted': '#9080a8', '--text-dim': '#6a5a80', '--accent': '#c084fc', '--accent-dim': '#9060d0', '--user-bg': '#2e2440', '--assistant-bg': '#241e30', '--border': '#3a2e50', '--scrollbar': '#3a2e50', '--scrollbar-hover': '#4a3e60' }, solar: { '--bg': '#1a1808', '--bg-secondary': '#24200c', '--bg-tertiary': '#3a3214', '--surface': '#2a2610', '--surface-hover': '#36301a', '--text': '#e8e0c8', '--text-muted': '#a09868', '--text-dim': '#706840', '--accent': '#facc15', '--accent-dim': '#c8a010', '--user-bg': '#3a3018', '--assistant-bg': '#2a2610', '--border': '#4a4020', '--scrollbar': '#4a4020', '--scrollbar-hover': '#5a5030' } }; const COLOR_PICKER_VARS = ['--bg','--bg-secondary','--surface','--accent','--text','--text-muted','--user-bg','--assistant-bg','--border']; const COLOR_PICKER_IDS = { '--bg': 'csBackground', '--bg-secondary': 'csSidebar', '--surface': 'csSurface', '--accent': 'csAccent', '--text': 'csText', '--text-muted': 'csMuted', '--user-bg': 'csUserBg', '--assistant-bg': 'csAssistantBg', '--border': 'csBorder' }; function getComputedCSSVar(varName) { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); } function syncColorPickers() { for (const [cssVar, elId] of Object.entries(COLOR_PICKER_IDS)) { const el = document.getElementById(elId); if (!el) continue; let val = document.documentElement.style.getPropertyValue(cssVar) || getComputedCSSVar(cssVar); // Ensure it's a proper hex for the picker if (val && !val.startsWith('#')) { const tmp = document.createElement('div'); tmp.style.color = val; document.body.appendChild(tmp); const rgb = getComputedStyle(tmp).color; document.body.removeChild(tmp); const m = rgb.match(/\d+/g); if (m) val = '#' + m.slice(0,3).map(n => (+n).toString(16).padStart(2,'0')).join(''); } if (val) el.value = val; } // Highlight active preset const btns = document.querySelectorAll('.color-preset-btn'); const currentPreset = localStorage.getItem('harness-color-preset') || ''; btns.forEach(b => { const name = b.textContent.toLowerCase(); b.classList.toggle('active', name === currentPreset); }); } function setCustomColor(input) { const cssVar = input.dataset.var; document.documentElement.style.setProperty(cssVar, input.value); // Save custom overrides const saved = JSON.parse(localStorage.getItem('harness-color-custom') || '{}'); saved[cssVar] = input.value; saveSettingToServer('harness-color-custom', saved); saveSettingToServer('harness-color-preset', 'custom'); document.querySelectorAll('.color-preset-btn').forEach(b => b.classList.remove('active')); } function applyColorPreset(name) { const preset = COLOR_PRESETS[name]; if (!preset) return; // Apply all vars from the preset for (const [cssVar, val] of Object.entries(preset)) { document.documentElement.style.setProperty(cssVar, val); } saveSettingToServer('harness-color-preset', name); saveSettingToServer('harness-color-custom', preset); syncColorPickers(); } function resetColorScheme() { // Remove all inline overrides for (const cssVar of Object.keys(COLOR_PRESETS.default)) { document.documentElement.style.removeProperty(cssVar); } localStorage.removeItem('harness-color-preset'); localStorage.removeItem('harness-color-custom'); // Clear on server too fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({'harness-color-preset': '', 'harness-color-custom': {}}) }).catch(() => {}); syncColorPickers(); document.querySelectorAll('.color-preset-btn').forEach(b => b.classList.remove('active')); } // ── Settings sync (server-side persistence) ── const SYNCED_SETTINGS_KEYS = [ 'harness-theme', 'harness-color-preset', 'harness-color-custom', 'budgetAmount', 'budgetSpent', 'budgetCycleDay', 'budgetCycleStart', 'relayNotifPrefs', 'notifSettings', 'userDefault_showThinking', 'userDefault_pureMode', 'userDefault_ttsEnabled', 'userDefault_codingMode' ]; function saveSettingToServer(key, value) { // Save to localStorage immediately localStorage.setItem(key, typeof value === 'object' ? JSON.stringify(value) : value); // Push to server in background const payload = {}; payload[key] = value; fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }).catch(() => {}); } async function loadServerSettings() { try { const res = await fetch('/api/settings'); if (!res.ok) return; const server = await res.json(); // Merge: server wins for keys not set locally, or always for colors/theme for (const key of SYNCED_SETTINGS_KEYS) { if (server[key] !== undefined) { const val = typeof server[key] === 'object' ? JSON.stringify(server[key]) : String(server[key]); localStorage.setItem(key, val); } } // Apply colors const colorData = server['harness-color-custom'] || localStorage.getItem('harness-color-custom'); if (colorData) { try { const colors = typeof colorData === 'string' ? JSON.parse(colorData) : colorData; for (const [cssVar, val] of Object.entries(colors)) { document.documentElement.style.setProperty(cssVar, val); } } catch(e) {} } // Apply theme const theme = server['harness-theme'] || localStorage.getItem('harness-theme'); if (theme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); const hlLink = document.querySelector('link[href*="highlight"]'); if (hlLink) hlLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; } // Apply notification prefs const np = server['relayNotifPrefs'] || localStorage.getItem('relayNotifPrefs'); if (np) { try { notifPrefs = typeof np === 'string' ? JSON.parse(np) : np; } catch(e) {} } // Apply global notification settings const ns = server['notifSettings'] || localStorage.getItem('notifSettings'); if (ns) { try { const parsed = typeof ns === 'string' ? JSON.parse(ns) : ns; for (const k in NOTIF_DEFAULTS) { if (parsed[k] !== undefined) notifSettings[k] = parsed[k]; } applyToastPosition(notifSettings.position || 'top-left'); } catch(e) {} } // Apply user-level defaults for thinking/pureMode/TTS const defThinking = server['userDefault_showThinking']; if (defThinking !== undefined) showThinking = defThinking === true || defThinking === 'true'; const defPure = server['userDefault_pureMode']; if (defPure !== undefined) pureMode = defPure === true || defPure === 'true'; const defTTS = server['userDefault_ttsEnabled']; if (defTTS !== undefined && (defTTS === true || defTTS === 'true') !== ttsEnabled) { toggleTTS(); } } catch(e) {} } // Fallback: apply localStorage immediately (before server fetch completes) (function loadSavedThinkingDefault() { const saved = localStorage.getItem('userDefault_showThinking'); if (saved !== null) showThinking = saved !== 'false'; })(); (function loadSavedColors() { const saved = localStorage.getItem('harness-color-custom'); if (saved) { try { const colors = JSON.parse(saved); for (const [cssVar, val] of Object.entries(colors)) { document.documentElement.style.setProperty(cssVar, val); } } catch(e) {} } })(); (function() { const saved = localStorage.getItem('harness-theme'); if (saved === 'light') { document.documentElement.setAttribute('data-theme', 'light'); const hlLink = document.querySelector('link[href*="highlight"]'); if (hlLink) hlLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; } })(); // ── Mobile keyboard fix ───────────────────── (function() { if (window.innerWidth > 768) return; const mainArea = document.querySelector('.main-area'); const textarea = document.getElementById('userInput'); let debounceTimer = null; let keyboardOpen = false; // Debounced layout pin — waits for keyboard animation to settle function pinLayout() { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { if (!window.visualViewport) return; const vvH = window.visualViewport.height; const vvTop = window.visualViewport.offsetTop; // Set main area to exactly the visible viewport mainArea.style.height = vvH + 'px'; mainArea.style.transform = 'translateY(' + vvTop + 'px)'; // Prevent body from scrolling behind window.scrollTo(0, 0); document.documentElement.scrollTop = 0; // Scroll chat to bottom so latest messages are visible if (keyboardOpen) { const chatArea = document.getElementById('chatArea'); if (chatArea) chatArea.scrollTop = chatArea.scrollHeight; } }, 150); } if (window.visualViewport) { window.visualViewport.addEventListener('resize', pinLayout); window.visualViewport.addEventListener('scroll', pinLayout); } textarea.addEventListener('focus', () => { keyboardOpen = true; // Single delayed pin after keyboard fully opens setTimeout(pinLayout, 400); }); textarea.addEventListener('blur', () => { keyboardOpen = false; clearTimeout(debounceTimer); setTimeout(() => { mainArea.style.height = '100dvh'; mainArea.style.transform = ''; window.scrollTo(0, 0); }, 200); }); // Initial pin pinLayout(); })(); // ── Presence / Viewers ───────────── let currentViewers = []; let viewersTooltipOpen = false; let presenceInterval = null; function updateViewersDisplay(viewers) { currentViewers = viewers; const countEl = document.getElementById('viewersCount'); if (countEl) countEl.textContent = viewers.length || 1; // Update tooltip if open if (viewersTooltipOpen) renderViewersTooltip(); } function toggleViewersTooltip() { const indicator = document.getElementById('viewersIndicator'); let tip = indicator.querySelector('.viewers-tooltip'); if (tip) { tip.remove(); viewersTooltipOpen = false; return; } viewersTooltipOpen = true; renderViewersTooltip(); } function renderViewersTooltip() { const indicator = document.getElementById('viewersIndicator'); let tip = indicator.querySelector('.viewers-tooltip'); if (!tip) { tip = document.createElement('div'); tip.className = 'viewers-tooltip'; indicator.appendChild(tip); } if (currentViewers.length === 0) { tip.innerHTML = '
No viewers
'; return; } tip.innerHTML = currentViewers.map(u => `
${u}
` ).join(''); } // Close viewers tooltip on outside click document.addEventListener('click', (e) => { if (viewersTooltipOpen && !e.target.closest('#viewersIndicator')) { const tip = document.querySelector('.viewers-tooltip'); if (tip) tip.remove(); viewersTooltipOpen = false; } }); // Poll global presence for sidebar online count + user list let onlinePresenceData = {}; let onlineDropdownOpen = false; async function pollPresence() { try { const resp = await fetch('/api/presence'); const data = await resp.json(); onlinePresenceData = data; const count = Object.keys(data).length; const el = document.getElementById('onlineNum'); if (el) el.textContent = count; if (onlineDropdownOpen) renderOnlineDropdown(); } catch (e) {} } function toggleOnlineDropdown() { const container = document.getElementById('onlineCount'); let dd = container.querySelector('.online-dropdown'); if (dd) { dd.remove(); onlineDropdownOpen = false; return; } onlineDropdownOpen = true; renderOnlineDropdown(); } function renderOnlineDropdown() { const container = document.getElementById('onlineCount'); let dd = container.querySelector('.online-dropdown'); if (!dd) { dd = document.createElement('div'); dd.className = 'online-dropdown'; container.appendChild(dd); } const users = Object.entries(onlinePresenceData); if (users.length === 0) { dd.innerHTML = '
No users online
'; return; } const myUsername = currentUser?.username; dd.innerHTML = users.map(([username, info]) => { const loc = info.location_type === 'session' ? 'session' : (info.location_id || 'room'); const inCall = vc.voiceInCalls[username]; const isMe = username === myUsername; const callBtn = !isMe ? ( inCall ? `` : ` ` ) : ''; return `
${username}${loc}${callBtn}
`; }).join(''); } // Close online dropdown on outside click document.addEventListener('click', (e) => { if (onlineDropdownOpen && !e.target.closest('#onlineCount')) { const dd = document.querySelector('.online-dropdown'); if (dd) dd.remove(); onlineDropdownOpen = false; } }); // Presence polling now handled by unified poll (5s) // presenceInterval removed — data comes from /api/poll // ── Sidebar Tabs (Chats / Contacts) ───────────── let contactsData = []; let contactsInterval = null; function switchSidebarTab(tab, el) { document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.sidebar-view').forEach(v => v.classList.remove('active')); el.classList.add('active'); if (tab === 'chats') { document.getElementById('sidebarChatsView').classList.add('active'); } else { document.getElementById('sidebarContactsView').classList.add('active'); // Contacts data comes from unified poll — just re-render renderContacts(); } } async function loadContacts() { try { const resp = await fetch('/api/contacts'); if (!resp.ok) return; contactsData = await resp.json(); syncRelayIdentitiesFromContacts(); renderContacts(); } catch(e) {} } // ── Relay DM state (declared in relay section above) ── function loadRelayUnread() { try { relayUnread = JSON.parse(localStorage.getItem('relayUnread') || '{}'); } catch(e) { relayUnread = {}; } } function saveRelayUnread() { localStorage.setItem('relayUnread', JSON.stringify(relayUnread)); } function clearUnreadFor(key) { if (relayUnread[key]) { delete relayUnread[key]; saveRelayUnread(); renderContacts(); updateRelayBadgeTotal(); } } function clearUnreadForCurrentView() { clearUnreadFor(relayDmTarget || '__feed__'); } function renderContacts() { const list = document.getElementById('contactList'); if (!list) return; if (contactsData.length === 0) { list.innerHTML = '
No contacts yet
'; return; } // Update feed badge const feedBadge = document.getElementById('feedUnreadBadge'); if (feedBadge) { const fc = relayUnread['__feed__'] || 0; feedBadge.textContent = fc; feedBadge.style.display = fc > 0 ? '' : 'none'; } list.innerHTML = contactsData.map(c => { const initials = (c.display_name || c.username).charAt(0).toUpperCase(); const statusDot = c.online ? 'online' : 'offline'; const unread = relayUnread[c.username] || 0; const unreadHtml = unread > 0 ? `${unread}` : ''; const activeCls = relayDmTarget === c.username ? ' active-dm' : ''; const unreadCls = unread > 0 ? ' has-unread' : ''; const badge = c.is_admin ? 'admin' : ''; // Last message preview — show ticker of last msg instead of status text const lastMsg = relayLastMsg[c.username]; let previewHtml = ''; if (lastMsg && lastMsg.text) { const preview = lastMsg.text.replace(/^\[[^\]]+\]\s*/, '').substring(0, 60); previewHtml = `
${escapeHtml(preview)}
`; } else { let statusText = c.online ? 'Online' : 'Offline'; if (c.online && c.location_type === 'session') statusText = 'In a session'; else if (c.online && c.location_type === 'room') statusText = 'In a room'; previewHtml = `
${statusText}
`; } return `
${initials}
${c.display_name || c.username} ${badge}
${previewHtml}
${unreadHtml}
`; }).join(''); // Update contacts tab badge and feed item const totalUnread = Object.values(relayUnread).reduce((a, b) => a + b, 0); const tabBadge = document.getElementById('contactsTabBadge'); if (tabBadge) { tabBadge.textContent = totalUnread; tabBadge.style.display = totalUnread > 0 ? '' : 'none'; } // Also add has-unread to feed item if feed has unread const feedItem = document.querySelector('.contact-feed-item'); if (feedItem) { const feedUnread = relayUnread['__feed__'] || 0; feedItem.classList.toggle('has-unread', feedUnread > 0); } } function openDmThread(username) { relayDmTarget = username; const contact = contactsData.find(c => c.username === username); const name = contact ? (contact.display_name || username) : username; // Update relay panel header document.getElementById('relayPanelTitle').textContent = name; document.getElementById('relayBackBtn').style.display = ''; // Clear unread for this person clearUnreadFor(username); // On mobile: close sidebar first, then ensure DM panel is visible const relayPanel = document.getElementById('relayPanel'); const isMobile = window.innerWidth <= 768; if (isMobile) { document.querySelector('.sidebar').classList.remove('open'); if (!relayPanel.classList.contains('mobile-open')) toggleRelayPanel(); } else { if (relayPanel.classList.contains('collapsed')) toggleRelayPanel(); } // Re-render messages filtered to this DM renderRelayMessages(); // Focus input const inp = document.getElementById('relayInput'); if (inp) { inp.placeholder = `Message ${name}...`; inp.focus(); } // Highlight active contact renderContacts(); } function relayShowFeed() { relayDmTarget = null; document.getElementById('relayPanelTitle').innerHTML = '📡 General Feed'; document.getElementById('relayBackBtn').style.display = 'none'; clearUnreadFor('__feed__'); const relayPanel = document.getElementById('relayPanel'); const isMobile = window.innerWidth <= 768; if (isMobile) { document.querySelector('.sidebar').classList.remove('open'); if (!relayPanel.classList.contains('mobile-open')) toggleRelayPanel(); } else { if (relayPanel.classList.contains('collapsed')) toggleRelayPanel(); } renderRelayMessages(); const inp = document.getElementById('relayInput'); if (inp) { inp.placeholder = 'Type a message...'; inp.focus(); } renderContacts(); } // Kept for relay panel toolbar button backward compat function sendRelayDM(username) { openDmThread(username); } // ── Contact context menu (notification prefs) ───────── let _ctxContactUser = null; function showContactCtx(e, username) { e.preventDefault(); e.stopPropagation(); _ctxContactUser = username; const menu = document.getElementById('contactCtxMenu'); // Position near click menu.style.left = Math.min(e.clientX, window.innerWidth - 220) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 280) + 'px'; menu.style.display = 'block'; // Update checkmarks const current = getNotifPref(username); ['all','toast','sound','read','off'].forEach(p => { const el = document.getElementById('ctxPref' + p.charAt(0).toUpperCase() + p.slice(1)); if (el) el.textContent = current === p ? '✓' : ''; }); } function setContactNotif(pref) { if (!_ctxContactUser) return; notifPrefs[_ctxContactUser] = pref; saveNotifPrefs(); document.getElementById('contactCtxMenu').style.display = 'none'; renderContacts(); } function openDmFromCtx() { document.getElementById('contactCtxMenu').style.display = 'none'; if (_ctxContactUser) openDmThread(_ctxContactUser); } // Close on outside click document.addEventListener('click', (e) => { const menu = document.getElementById('contactCtxMenu'); if (menu && menu.style.display !== 'none' && !menu.contains(e.target)) { menu.style.display = 'none'; } }); // Heartbeat is now a side-effect of the unified poll (every 5s) // No separate timer needed // ── Wake Lock (keep screen on) ───────────── let wakeLock = null; async function requestWakeLock() { try { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); console.log('Wake lock acquired — screen will stay on'); wakeLock.addEventListener('release', () => { console.log('Wake lock released'); wakeLock = null; }); } } catch (e) { console.log('Wake lock failed:', e.message); } } // Request on load requestWakeLock(); // Re-acquire when tab becomes visible again (browser releases it on tab switch) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !wakeLock) { requestWakeLock(); } }); // ══════════════════════════════════════════════════════════ // Multi-User Auth & Room System // ══════════════════════════════════════════════════════════ let currentUser = null; // {username, display_name, token} let currentRoomId = null; // currently viewed room (null when viewing a session) let _connectedRoomId = null; // room we have a WS connection to (persists across views) let roomWs = null; let atClaudeActive = false; let _viewMode = 'session'; // 'session' or 'room' // ── Auth ── async function checkAuth() { try { const res = await fetch('/api/auth/me'); if (res.ok) { currentUser = await res.json(); // Token now returned by /api/auth/me (httponly cookie can't be read from JS) document.getElementById('loginScreen').classList.add('hidden'); document.getElementById('currentUserBadge').textContent = currentUser.display_name; document.getElementById('roomUserBadge').textContent = currentUser.display_name; loadRooms(); loadServerSettings(); // Load relay routing config to show relay target indicator try { const rr = await fetch('/api/relay/routing'); if (rr.ok) { const cfg = await rr.json(); if (cfg.mode === 'specific' && cfg.target_session) { relayTargetSessionId = cfg.target_session; } } } catch(e) {} // Preload relay history to seed contact previews + unread counts loadRelayMessages(); startBgRelayPoll(); return true; } } catch(e) {} document.getElementById('loginScreen').classList.remove('hidden'); return false; } async function showRegister() { document.getElementById('loginForm').style.display = 'none'; document.getElementById('registerForm').style.display = 'block'; document.getElementById('loginError').style.display = 'none'; // Check if invite codes are required try { const res = await fetch('/api/auth/registration-mode'); const data = await res.json(); const invField = document.getElementById('regInviteCode'); invField.style.display = data.require_invite ? 'block' : 'none'; invField.required = data.require_invite; } catch(e) {} } function togglePwVis(inputId, btn) { const inp = document.getElementById(inputId); if (inp.type === 'password') { inp.type = 'text'; btn.textContent = '🙈'; btn.title = 'Hide password'; } else { inp.type = 'password'; btn.textContent = '👁'; btn.title = 'Show password'; } } function showLogin() { document.getElementById('loginForm').style.display = 'block'; document.getElementById('registerForm').style.display = 'none'; document.getElementById('loginError').style.display = 'none'; } async function doLogin() { const username = document.getElementById('loginUsername').value.trim(); const password = document.getElementById('loginPassword').value; if (!username || !password) return; try { const res = await fetch('/api/auth/login', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username, password}) }); if (res.ok) { const data = await res.json(); currentUser = data; document.getElementById('loginScreen').classList.add('hidden'); document.getElementById('currentUserBadge').textContent = data.display_name; document.getElementById('roomUserBadge').textContent = data.display_name; loadRooms(); loadServerSettings(); loadSessions(); vcConnectWs(); try { if (typeof Notification !== 'undefined' && Notification.permission === 'default') Notification.requestPermission(); } catch(e) {} } else { const err = await res.json(); document.getElementById('loginError').textContent = err.detail || 'Login failed'; document.getElementById('loginError').style.display = 'block'; } } catch(e) { document.getElementById('loginError').textContent = 'Connection error'; document.getElementById('loginError').style.display = 'block'; } } async function doRegister() { const username = document.getElementById('regUsername').value.trim(); const display_name = document.getElementById('regDisplayName').value.trim(); const email = document.getElementById('regEmail').value.trim(); const password = document.getElementById('regPassword').value; const invite_code = document.getElementById('regInviteCode').value.trim(); if (!username || !password) return; try { const res = await fetch('/api/auth/register', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username, password, display_name, email, invite_code}) }); if (res.ok) { const data = await res.json(); currentUser = data; document.getElementById('loginScreen').classList.add('hidden'); document.getElementById('currentUserBadge').textContent = data.display_name; document.getElementById('roomUserBadge').textContent = data.display_name; loadRooms(); loadServerSettings(); loadSessions(); } else { const err = await res.json(); document.getElementById('loginError').textContent = err.detail || 'Registration failed'; document.getElementById('loginError').style.display = 'block'; } } catch(e) { document.getElementById('loginError').textContent = 'Connection error'; document.getElementById('loginError').style.display = 'block'; } } async function doLogout() { await fetch('/api/auth/logout', {method: 'POST'}); currentUser = null; if (roomWs) { roomWs.close(); roomWs = null; } document.getElementById('loginScreen').classList.remove('hidden'); } // ── Account Manager ── async function openAccountManager() { document.getElementById('accountModal').style.display = 'flex'; document.getElementById('acctCurrentPw').value = ''; document.getElementById('acctNewPw').value = ''; document.getElementById('acctConfirmPw').value = ''; document.getElementById('acctPwStatus').textContent = ''; document.getElementById('acctProfileStatus').textContent = ''; // Fetch full profile try { const res = await fetch('/api/auth/me'); if (res.ok) { const me = await res.json(); currentUser = {...currentUser, ...me}; document.getElementById('acctUsername').textContent = me.username; document.getElementById('acctDisplayName').value = me.display_name || ''; document.getElementById('acctEmail').value = me.email || ''; document.getElementById('acctRelayKey').value = me.relay_key || 'none'; document.getElementById('acctRelayIdentity').textContent = me.relay_identity || me.username + '_claude'; const tierEl = document.getElementById('acctTier'); const tierToolsEl = document.getElementById('acctTierTools'); const tier = me.permission_tier || 'standard'; tierEl.textContent = tier; const toolsMap = {admin: 'Bash, Read, Edit, Write, Grep, Glob', trusted: 'Bash, Read, Edit, Write, Grep, Glob', standard: 'Read, Edit, Write, Grep, Glob (no Bash)', readonly: 'Read, Grep, Glob (view only)'}; tierToolsEl.textContent = '— ' + (toolsMap[tier] || 'unknown'); } } catch(e) {} // Show admin section only for admin const adminSection = document.getElementById('adminUserSection'); if (currentUser.is_admin || currentUser.username === 'sylvan' || currentUser.username === 'obi') { adminSection.style.display = 'block'; loadAdminUsers(); loadInvites(); loadRelayRouting(); sentinelLoadStatus(); } else { adminSection.style.display = 'none'; } } function closeAccountManager() { document.getElementById('accountModal').style.display = 'none'; } async function saveProfile() { const name = document.getElementById('acctDisplayName').value.trim(); const email = document.getElementById('acctEmail').value.trim(); if (!name) return; const status = document.getElementById('acctProfileStatus'); try { const res = await fetch('/api/auth/update-profile', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({display_name: name, email: email}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } const data = await res.json(); currentUser.display_name = data.display_name; status.textContent = 'Profile updated'; status.style.color = '#10b981'; setTimeout(() => status.textContent = '', 2000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function copyRelayKey() { const key = document.getElementById('acctRelayKey').value; try { await navigator.clipboard.writeText(key); const btn = document.getElementById('copyKeyBtn'); btn.textContent = '\u2713'; setTimeout(() => btn.textContent = '\ud83d\udccb', 1500); } catch(e) { document.getElementById('acctRelayKey').select(); } } async function regenerateRelayKey() { const pw = prompt('Enter your password to regenerate relay key:'); if (!pw) return; try { const res = await fetch('/api/auth/regenerate-key', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: pw}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } const data = await res.json(); document.getElementById('acctRelayKey').value = data.relay_key; alert('Relay key regenerated. You will need to push keys to the relay server for the new key to work.'); } catch(e) { alert(e.message); } } async function loadRelayRouting() { try { const res = await fetch('/api/relay/routing'); if (!res.ok) return; const config = await res.json(); const radios = document.querySelectorAll('input[name="relayRouting"]'); radios.forEach(r => { if (r.value === 'specific') { r.checked = config.mode === 'specific'; } else { r.checked = r.value === config.mode; } }); document.getElementById('relayAutoRespond').checked = config.auto_respond !== false; // Show which session is locked if specific mode if (config.mode === 'specific' && config.target_session) { const label = document.querySelector('input[name="relayRouting"][value="specific"]').parentElement; const sid = config.target_session.substring(0, 8); label.querySelector('.relay-target-id')?.remove(); const span = document.createElement('span'); span.className = 'relay-target-id'; span.style.cssText = 'color:var(--accent-color);font-size:11px;margin-left:4px;'; span.textContent = `(${sid})`; label.appendChild(span); } } catch(e) {} } async function setRelayRouting(mode, targetSession) { try { const body = {mode}; if (mode === 'specific' && targetSession) { body.target_session = targetSession; } await fetch('/api/relay/routing', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }); } catch(e) { alert(e.message); } } async function setRelayAutoRespond(enabled) { try { await fetch('/api/relay/routing', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({auto_respond: enabled}) }); } catch(e) { alert(e.message); } } async function pushRelayKeys() { const adminPw = prompt('Enter admin password to push keys:'); if (!adminPw) return; const status = document.getElementById('pushKeysStatus'); status.textContent = 'Pushing...'; status.style.color = '#f59e0b'; try { const res = await fetch('/api/admin/push-relay-keys', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({admin_password: adminPw}) }); const data = await res.json(); if (data.ok) { status.textContent = `Pushed ${data.keys_pushed} keys`; status.style.color = '#10b981'; } else { status.textContent = data.error || 'Failed'; status.style.color = '#ef4444'; } setTimeout(() => status.textContent = '', 5000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } // --- Sentinel Functions --- async function sentinelLoadStatus() { try { const r = await fetch('/api/admin/sentinel/status'); if (!r.ok) return; const data = await r.json(); const el = document.getElementById('sentinelStatus'); const activateBtn = document.getElementById('sentinelActivateBtn'); const deactivateBtn = document.getElementById('sentinelDeactivateBtn'); if (data.active) { el.innerHTML = `● ACTIVE since ${new Date(data.activated_at).toLocaleString()}
` + `${data.evidence_files} evidence files (${data.evidence_size_mb}MB)`; activateBtn.style.display = 'none'; deactivateBtn.style.display = ''; } else { el.innerHTML = '● Inactive' + (data.evidence_files > 0 ? ` — ${data.evidence_files} files from previous activation` : ''); activateBtn.style.display = ''; deactivateBtn.style.display = 'none'; } } catch(e) { document.getElementById('sentinelStatus').textContent = 'Error loading status'; } } async function sentinelActivate() { const pw = prompt('Set sentinel activation password (needed to deactivate):'); if (!pw) return; if (!confirm('⚠️ ACTIVATE SENTINEL?\n\nThis will start:\n• Webcam capture every 30s\n• Screenshot every 60s\n• Audio recording every 2min\n• Wifi geolocation every 60s\n• Evidence upload to relay\n\nActivate?')) return; try { const r = await fetch('/api/admin/sentinel/activate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: pw}) }); const data = await r.json(); document.getElementById('sentinelResult').innerHTML = `ACTIVATED — PID ${data.pid}`; sentinelLoadStatus(); } catch(e) { document.getElementById('sentinelResult').textContent = 'Activation failed: ' + e.message; } } async function sentinelDeactivate() { const pw = prompt('Enter sentinel activation password to deactivate:'); if (!pw) return; try { const r = await fetch('/api/admin/sentinel/deactivate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: pw}) }); const data = await r.json(); if (data.status === 'denied') { document.getElementById('sentinelResult').innerHTML = 'Wrong password — cannot deactivate'; } else { document.getElementById('sentinelResult').innerHTML = 'Deactivated'; sentinelLoadStatus(); } } catch(e) { document.getElementById('sentinelResult').textContent = 'Error: ' + e.message; } } async function sentinelTest() { const el = document.getElementById('sentinelResult'); el.innerHTML = 'Running test capture...'; try { const r = await fetch('/api/admin/sentinel/test', { method: 'POST' }); const data = await r.json(); let html = '
'; html += `Webcam: ${data.webcam ? '✅ ' + data.webcam.split('/').pop() : '❌ Failed'}
`; html += `Screenshot: ${data.screenshot ? '✅ ' + data.screenshot.split('/').pop() : '❌ Failed'}
`; html += `Wifi: ${data.wifi?.path ? '✅ ' + data.wifi.networks + ' networks' : '❌ Failed'}
`; html += `External IP: ${data.system?.external_ip || 'unknown'}
`; html += `Uptime: ${data.system?.uptime || 'unknown'}
`; html += `Logged in: ${data.system?.logged_in_users || 'unknown'}`; html += '
'; el.innerHTML = html; } catch(e) { el.textContent = 'Test failed: ' + e.message; } } async function sentinelEvidence() { const el = document.getElementById('sentinelResult'); el.innerHTML = 'Loading evidence...'; try { const r = await fetch('/api/admin/sentinel/evidence'); const data = await r.json(); if (!data.files || data.files.length === 0) { el.innerHTML = 'No evidence files'; return; } let html = '
'; for (const f of data.files.slice(0, 30)) { const icon = f.category === 'webcam' ? '📷' : f.category === 'screenshot' ? '🖥️' : f.category === 'audio' ? '🎤' : f.category === 'wifi' ? '📡' : '📄'; const size = f.size > 1024*1024 ? (f.size/1024/1024).toFixed(1)+'MB' : (f.size/1024).toFixed(0)+'KB'; const time = new Date(f.modified).toLocaleTimeString(); const isImg = f.path.endsWith('.jpg') || f.path.endsWith('.png'); if (isImg) { html += `${icon} ${f.path.split('/').pop()} (${size}) ${time}
`; } else { html += `${icon} ${f.path.split('/').pop()} (${size}) ${time}
`; } } if (data.files.length > 30) html += `
... and ${data.files.length - 30} more files`; html += '
'; el.innerHTML = html; } catch(e) { el.textContent = 'Error loading evidence: ' + e.message; } } // Load sentinel status when account panel opens const _origToggleAcct = typeof toggleAccountPanel === 'function' ? toggleAccountPanel : null; async function changePassword() { const current = document.getElementById('acctCurrentPw').value; const newPw = document.getElementById('acctNewPw').value; const confirm = document.getElementById('acctConfirmPw').value; const status = document.getElementById('acctPwStatus'); if (!current || !newPw) { status.textContent = 'Fill in all fields'; status.style.color = '#ef4444'; return; } if (newPw !== confirm) { status.textContent = 'Passwords do not match'; status.style.color = '#ef4444'; return; } if (newPw.length < 4) { status.textContent = 'Minimum 4 characters'; status.style.color = '#ef4444'; return; } try { const res = await fetch('/api/auth/change-password', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: current, new_password: newPw}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } status.textContent = 'Password updated'; status.style.color = '#10b981'; document.getElementById('acctCurrentPw').value = ''; document.getElementById('acctNewPw').value = ''; document.getElementById('acctConfirmPw').value = ''; setTimeout(() => status.textContent = '', 3000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function loadAdminUsers() { const list = document.getElementById('adminUserList'); try { const res = await fetch('/api/admin/users'); if (!res.ok) { list.innerHTML = '

Failed to load users

'; return; } const users = await res.json(); list.innerHTML = users.map(u => `
${escapeHtml(u.display_name)} @${escapeHtml(u.username)} ${u.is_admin ? '(admin)' : ''}
${!u.is_admin ? ` ` : ''}
${u.email ? '✉ ' + escapeHtml(u.email) + '' : 'no email'} 🔑 ${u.relay_identity || u.username + '_claude'}
`).join(''); } catch(e) { list.innerHTML = '

Error loading users

'; } } async function adminCreateUser() { const username = document.getElementById('adminNewUsername').value.trim().toLowerCase(); const displayName = document.getElementById('adminNewDisplayName').value.trim(); const email = document.getElementById('adminNewEmail').value.trim(); const password = document.getElementById('adminNewPassword').value; const status = document.getElementById('adminStatus'); if (!username || !password) { status.textContent = 'Username and password required'; status.style.color = '#ef4444'; return; } const adminPw = prompt('Enter admin password to confirm:'); if (!adminPw) return; try { const res = await fetch('/api/admin/users', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username, display_name: displayName || username, email, password, admin_password: adminPw}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } const data = await res.json(); status.textContent = `User "${username}" created — relay key: ${data.relay_key ? data.relay_key.substring(0, 12) + '...' : 'none'}`; status.style.color = '#10b981'; document.getElementById('adminNewUsername').value = ''; document.getElementById('adminNewDisplayName').value = ''; document.getElementById('adminNewEmail').value = ''; document.getElementById('adminNewPassword').value = ''; loadAdminUsers(); setTimeout(() => status.textContent = '', 5000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function adminResetPassword(username) { const newPw = prompt(`New password for ${username}:`); if (!newPw) return; const adminPw = prompt('Enter admin password to confirm:'); if (!adminPw) return; const status = document.getElementById('adminStatus'); try { const res = await fetch(`/api/admin/users/${username}/reset-password`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({new_password: newPw, admin_password: adminPw}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } status.textContent = `Password reset for ${username}`; status.style.color = '#10b981'; setTimeout(() => status.textContent = '', 3000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function adminDeleteUser(username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; const status = document.getElementById('adminStatus'); try { const res = await fetch(`/api/admin/users/${username}`, {method: 'DELETE'}); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } status.textContent = `User "${username}" deleted`; status.style.color = '#10b981'; loadAdminUsers(); setTimeout(() => status.textContent = '', 3000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function loadInvites() { try { const res = await fetch('/api/admin/invites'); if (!res.ok) return; const invites = await res.json(); const el = document.getElementById('inviteList'); if (!invites.length) { el.innerHTML = 'No invite codes yet'; return; } el.innerHTML = invites.map(inv => { const status = inv.active ? `${inv.uses}/${inv.max_uses} used` : `revoked`; const usedBy = inv.used_by.length ? ` — ${inv.used_by.map(u => u.username).join(', ')}` : ''; const revokeBtn = inv.active ? `` : ''; const copyBtn = inv.active ? `` : ''; return `
${inv.code} ${status}${usedBy} ${inv.note ? `${inv.note}` : ''} ${copyBtn}${revokeBtn}
`; }).join(''); } catch(e) {} } async function createInvite() { const note = document.getElementById('inviteNote').value.trim(); const max_uses = parseInt(document.getElementById('inviteMaxUses').value) || 1; const pw = prompt('Admin password:'); if (!pw) return; const status = document.getElementById('inviteStatus'); try { const res = await fetch('/api/admin/invites', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({admin_password: pw, max_uses, note}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } const data = await res.json(); status.textContent = `Invite created: ${data.code}`; status.style.color = '#8b5cf6'; navigator.clipboard.writeText(data.code); document.getElementById('inviteNote').value = ''; loadInvites(); setTimeout(() => status.textContent = '', 5000); } catch(e) { status.textContent = e.message; status.style.color = '#ef4444'; } } async function revokeInvite(code) { if (!confirm(`Revoke invite code "${code}"?`)) return; const pw = prompt('Admin password:'); if (!pw) return; try { const res = await fetch(`/api/admin/invites/${code}`, { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({admin_password: pw}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } loadInvites(); } catch(e) { alert(e.message); } } async function setUserTier(username, tier) { try { const res = await fetch(`/api/admin/users/${username}/tier`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({tier}) }); if (!res.ok) { const e = await res.json(); throw new Error(e.detail); } const status = document.getElementById('adminStatus'); status.textContent = `${username} set to "${tier}" — tools: ${tier === 'admin' || tier === 'trusted' ? 'all' : tier === 'standard' ? 'no bash' : 'read only'}`; status.style.color = '#10b981'; setTimeout(() => status.textContent = '', 3000); } catch(e) { alert('Failed to set tier: ' + e.message); } } // Allow Enter to submit login document.getElementById('loginPassword').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); document.getElementById('regPassword').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); }); // ── Rooms ── async function loadRooms() { try { const res = await fetch('/api/rooms'); if (!res.ok) return; const rooms = await res.json(); const list = document.getElementById('roomList'); list.innerHTML = ''; rooms.forEach(r => { const div = document.createElement('div'); div.className = 'room-list-item' + (currentRoomId === r.id ? ' active' : ''); div.innerHTML = `💬 ${r.name} ${(r.members||[]).length}`; div.onclick = () => switchToRoom(r.id); list.appendChild(div); }); } catch(e) { console.error('loadRooms error:', e); } } async function createNewRoom() { const name = prompt('Room name:'); if (!name) return; try { const res = await fetch('/api/rooms', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name}) }); if (res.ok) { const data = await res.json(); loadRooms(); switchToRoom(data.room_id); } } catch(e) {} } function switchToRoom(roomId) { _viewMode = 'room'; // Leave voice call if switching to a DIFFERENT room if (vc.inCall && _connectedRoomId && _connectedRoomId !== roomId) { vcLeaveCall(); } currentRoomId = roomId; // Show room panel, hide session panel document.getElementById('roomPanel').classList.add('active'); document.getElementById('sessionPanel').style.display = 'none'; // Update sidebar active states document.querySelectorAll('.room-list-item').forEach(el => el.classList.remove('active')); document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active')); const items = document.querySelectorAll('.room-list-item'); items.forEach(el => { if (el.textContent.includes(roomId)) el.classList.add('active'); }); loadRooms(); // refresh to show active state // Only reconnect WS if switching to a different room if (_connectedRoomId !== roomId) { if (roomWs) { roomWs.onclose = null; roomWs.close(); roomWs = null; } _connectedRoomId = roomId; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/room/${roomId}`; roomWs = new WebSocket(wsUrl); roomWs.onmessage = (event) => { const data = JSON.parse(event.data); handleRoomMessage(data); }; roomWs.onclose = () => { _connectedRoomId = null; setTimeout(() => { if (_connectedRoomId === null && (currentRoomId === roomId || vc.inCall)) { switchToRoom(roomId); } }, 3000); }; } // Close mobile sidebar const sidebar = document.querySelector('.sidebar'); if (sidebar) sidebar.classList.remove('open'); document.getElementById('mobileOverlay').classList.remove('active'); } function switchToSession(sessionId) { _viewMode = 'session'; // Don't kill voice call or room WS — stay connected in background // Only clear UI focus, not the connection currentRoomId = null; // Hide room panel, show session panel document.getElementById('roomPanel').classList.remove('active'); document.getElementById('sessionPanel').style.display = ''; // Update sidebar active states document.querySelectorAll('.room-list-item').forEach(el => el.classList.remove('active')); // Call existing switchSession switchSession(sessionId); } // Override the existing session click handler const _origSwitchSession = typeof switchSession === 'function' ? switchSession : null; function handleRoomMessage(data) { if (data.type === 'room_state') { // Full room state on connect const room = data.room; document.getElementById('roomName').textContent = room.name; updateClaudeToggle(room.claude_enabled); renderRoomUsers(room.members, data.online || []); const msgContainer = document.getElementById('roomMessages'); msgContainer.innerHTML = ''; room.messages.forEach(m => appendRoomMsg(m)); msgContainer.scrollTop = msgContainer.scrollHeight; } else if (data.type === 'room_message' || data.username) { appendRoomMsg(data); const msgContainer = document.getElementById('roomMessages'); msgContainer.scrollTop = msgContainer.scrollHeight; } else if (data.type === 'claude_typing') { // Show typing indicator for Claude appendRoomMsg({username: 'claude', content: '...thinking...', type: 'system', timestamp: data.timestamp}); } else if (data.type === 'typing') { // Could show typing indicator for user } else if (data.type === 'user_joined' || data.type === 'user_left') { renderRoomUsers(data.members || [], data.online || []); } } function appendRoomMsg(msg) { const container = document.getElementById('roomMessages'); const div = document.createElement('div'); const isClaude = msg.type === 'claude' || msg.username === 'claude'; const isSystem = msg.type === 'system'; div.className = 'room-msg' + (isClaude ? ' claude-msg' : '') + (isSystem ? ' system-msg' : ''); const ts = msg.timestamp ? new Date(msg.timestamp) : new Date(); const timeStr = ts.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'}); if (isSystem && msg.username !== 'claude') { div.innerHTML = `${msg.content} ${timeStr}`; } else { const nameClass = isClaude ? 'claude-name' : ''; const displayName = isClaude ? 'Claude' : (msg.display_name || msg.username || 'unknown'); // Render content with markdown for Claude messages let contentHtml = msg.content; if (isClaude && typeof marked !== 'undefined') { try { contentHtml = marked.parse(msg.content); } catch(e) {} } div.innerHTML = `${displayName}${timeStr}
${contentHtml}
`; } container.appendChild(div); } function renderRoomUsers(members, online) { const bar = document.getElementById('roomUsersBar'); bar.innerHTML = ''; members.forEach(m => { const isOnline = online.includes(m); const chip = document.createElement('div'); chip.className = 'room-user-chip'; chip.innerHTML = ` ${m}`; bar.appendChild(chip); }); // Always show Claude as a "member" const claudeChip = document.createElement('div'); claudeChip.className = 'room-user-chip'; claudeChip.innerHTML = ` Claude`; bar.appendChild(claudeChip); } function updateClaudeToggle(enabled) { const btn = document.getElementById('claudeToggleHeader'); btn.textContent = '@claude: ' + (enabled ? 'ON' : 'OFF'); btn.className = 'claude-toggle' + (enabled ? ' active' : ''); } function toggleClaudeInRoom() { if (roomWs && roomWs.readyState === WebSocket.OPEN) { roomWs.send(JSON.stringify({type: 'toggle_claude'})); } } function toggleAtClaude() { atClaudeActive = !atClaudeActive; const btn = document.getElementById('claudeToggleMsg'); btn.className = 'claude-toggle' + (atClaudeActive ? ' active' : ''); } function sendRoomMessage() { const input = document.getElementById('roomInput'); const content = input.value.trim(); if (!content || !roomWs || roomWs.readyState !== WebSocket.OPEN) return; roomWs.send(JSON.stringify({ type: 'message', content: content, invoke_claude: atClaudeActive })); input.value = ''; input.style.height = 'auto'; } // ══════════════════════════════════════════════════════════════ // Voice/Video Collaboration Engine (Global — persistent across views) // ══════════════════════════════════════════════════════════════ // State const vc = { ws: null, // dedicated voice WebSocket wsReconnectTimer: null, inCall: false, callId: null, // current call ID callType: null, // 'p2p' | 'group' localStream: null, // getUserMedia stream screenStream: null, // getDisplayMedia stream audioCtx: null, // AudioContext for processing gainNode: null, // input gain peers: {}, // username -> {pc, audioEl, videoEl, tile, volume, stream, dataChannel} iceConfig: null, // STUN/TURN servers micMuted: false, camOn: false, screenOn: false, pttMode: 'always', // always | ptt | vad pttActive: false, layout: 'grid', // grid | speaker | sidebar panelMode: 'floating', // floating | expanded | pip pinnedUser: null, speakingUsers: new Set(), noiseGateEnabled: true, noiseThreshold: -40, audioSource: 'mic', // mic | system | both dragState: null, tileOrder: [], wsConnected: false, // voice WS connection status incomingCall: null, // {call_id, from, display_name, video} voiceOnline: [], // users with voice WS connected voiceInCalls: {}, // username -> call_id fileTransfers: {}, // transfer_id -> {pc, channel, file, chunks, progress} }; // ── Voice WebSocket (global, persistent) ── function vcConnectWs() { if (vc.ws && vc.ws.readyState === WebSocket.OPEN) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = currentUser?.token || document.cookie.match(/auth_token=([^;]+)/)?.[1] || ''; vc.ws = new WebSocket(`${proto}//${location.host}/ws/voice?token=${token}`); vc.ws.onopen = () => { console.log('[Voice WS] Connected'); vc.wsConnected = true; vcUpdateVoiceIndicator(); }; vc.ws.onclose = (e) => { console.log('[Voice WS] Disconnected, code:', e.code, e.reason || ''); vc.wsConnected = false; vcUpdateVoiceIndicator(); if (!vc.wsReconnectTimer) { vc.wsReconnectTimer = setTimeout(() => { vc.wsReconnectTimer = null; vcConnectWs(); }, 3000); } }; vc.ws.onerror = (e) => { console.error('[Voice WS] Error:', e); vc.wsConnected = false; vcUpdateVoiceIndicator(); }; vc.ws.onmessage = (e) => { try { vcHandleMessage(JSON.parse(e.data)); } catch(err) { console.error('[Voice WS] Parse error:', err); } }; // Keepalive clearInterval(vc._pingInterval); vc._pingInterval = setInterval(() => { if (vc.ws && vc.ws.readyState === WebSocket.OPEN) vc.ws.send(JSON.stringify({type: 'ping'})); }, 25000); } function vcSend(msg) { if (vc.ws && vc.ws.readyState === WebSocket.OPEN) { vc.ws.send(JSON.stringify(msg)); return true; } console.warn('[Voice] Cannot send — WS not connected'); return false; } function vcUpdateVoiceIndicator() { const btn = document.getElementById('voiceJoinBtn'); if (!btn) return; if (vc.wsConnected) { btn.title = 'Join voice/video call'; btn.style.opacity = '1'; } else { btn.title = 'Voice disconnected — reconnecting...'; btn.style.opacity = '0.4'; } } // ── Opus SDP Munging (crystal clear mode) ── function vcMungeOpusSDP(sdp) { return sdp.replace(/a=fmtp:111 /g, 'a=fmtp:111 minptime=10;useinbandfec=1;maxaveragebitrate=128000;' + 'maxplaybackrate=48000;sprop-maxcapturerate=48000;stereo=0;usedtx=0;cbr=0;' ); } // ── Initiate Calls ── async function vcCallUser(target, withVideo) { if (vc.inCall) { console.warn('Already in a call'); return; } if (!vc.wsConnected) { alert('Voice not connected — reconnecting, try again in a moment'); vcConnectWs(); return; } vc.inCall = true; // Block immediately to prevent duplicate clicks await vcPrepareMedia(withVideo); if (!vcSend({type: 'call_user', target, video: !!withVideo})) { vc.inCall = false; // Reset on failure alert('Voice connection lost'); return; } document.getElementById('vcStatusLabel').textContent = 'Ringing...'; vcShowPanel(); } async function vcJoinGroupCall(roomId) { if (vc.inCall) { console.warn('Already in a call'); return; } if (!vc.wsConnected) { alert('Voice not connected — reconnecting, try again in a moment'); vcConnectWs(); return; } vc.inCall = true; // Block immediately await vcPrepareMedia(false); if (!vcSend({type: 'join_group_call', room_id: roomId, audio: true, video: false, ptt_mode: vc.pttMode})) { vc.inCall = false; alert('Voice connection lost'); return; } // Show panel immediately — don't wait for server response vcShowPanel(); document.getElementById('vcStatusLabel').textContent = 'Joining group call...'; } // For the room voice join button (backwards compat) async function vcToggleCall() { if (vc.inCall) { vcHangup(); } else if (currentRoomId) { await vcJoinGroupCall(currentRoomId); } } async function vcPrepareMedia(withVideo) { // Get RTC config (TURN + STUN) try { const res = await fetch('/api/voice/rtc-config', {credentials: 'include'}); vc.iceConfig = await res.json(); } catch(e) { vc.iceConfig = {iceServers: [{urls: 'stun:stun.l.google.com:19302'}]}; } // Get local audio (+video if requested) try { const devices = await navigator.mediaDevices.enumerateDevices(); vcPopulateDevices(devices); const constraints = { audio: {echoCancellation: true, noiseSuppression: true, autoGainControl: true}, video: withVideo ? {width: {ideal: 640}, height: {ideal: 480}, frameRate: {ideal: 30}} : false, }; vc.localStream = await navigator.mediaDevices.getUserMedia(constraints); vcSetupAudioProcessing(); } catch(e) { console.error('Media access failed:', e); vc.localStream = null; } vc.micMuted = false; vc.camOn = !!withVideo; vc.screenOn = false; } function vcShowPanel() { const panel = document.getElementById('voicePanel'); if (panel.style.display === 'flex') return; // Already showing panel.style.display = 'flex'; panel.classList.add('active'); vcAddTile('__local__', currentUser.display_name || currentUser.username, true); vcUpdateToolbar(); vcMakeDraggablePanel(panel); } function vcHangup() { // Tell server vcSend({type: 'call_hangup'}); vcCleanupCall(); } function vcCleanupCall() { // Close all peer connections for (const [user, peer] of Object.entries(vc.peers)) { if (peer.pc) peer.pc.close(); if (peer.dataChannel) peer.dataChannel.close(); } vc.peers = {}; vc.tileOrder = []; // Stop local streams if (vc.localStream) { vc.localStream.getTracks().forEach(t => t.stop()); vc.localStream = null; } if (vc.screenStream) { vc.screenStream.getTracks().forEach(t => t.stop()); vc.screenStream = null; } if (vc.audioCtx) { vc.audioCtx.close(); vc.audioCtx = null; } vc.inCall = false; vc.callId = null; vc.callType = null; vc.camOn = false; vc.screenOn = false; // Hide panel const panel = document.getElementById('voicePanel'); panel.style.display = 'none'; panel.classList.remove('active'); document.getElementById('voiceGrid').innerHTML = ''; document.getElementById('vcStatusLabel').textContent = 'Not connected'; // Update room join button if visible const joinBtn = document.getElementById('voiceJoinBtn'); if (joinBtn) { joinBtn.classList.remove('active'); joinBtn.style.background = ''; joinBtn.style.color = ''; } } // ── Incoming Call ── function vcShowIncoming(data) { vc.incomingCall = data; document.getElementById('vcIncomingFrom').textContent = `${data.display_name || data.from} is calling`; document.getElementById('vcIncomingType').textContent = data.video ? 'Video call' : 'Voice call'; document.getElementById('vcIncomingCall').style.display = 'block'; // Ring sound (browser notification) try { if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { new Notification('Incoming call', {body: `${data.display_name || data.from} is calling`}); } } catch(e) {} } async function vcAcceptIncoming() { const data = vc.incomingCall; if (!data) return; document.getElementById('vcIncomingCall').style.display = 'none'; await vcPrepareMedia(data.video); vcSend({type: 'call_accept', call_id: data.call_id, video: data.video}); } function vcRejectIncoming() { const data = vc.incomingCall; if (!data) return; document.getElementById('vcIncomingCall').style.display = 'none'; vcSend({type: 'call_reject', call_id: data.call_id}); vc.incomingCall = null; } // ── Audio Processing Chain ── function vcSetupAudioProcessing() { if (!vc.localStream) return; vc.audioCtx = new AudioContext(); const source = vc.audioCtx.createMediaStreamSource(vc.localStream); vc.gainNode = vc.audioCtx.createGain(); vc.gainNode.gain.value = 1.0; source.connect(vc.gainNode); // Noise gate via analyser — check volume level const analyser = vc.audioCtx.createAnalyser(); analyser.fftSize = 256; vc.gainNode.connect(analyser); vc._analyser = analyser; vc._analyserData = new Uint8Array(analyser.frequencyBinCount); } function vcGetInputLevel() { if (!vc._analyser) return 0; vc._analyser.getByteFrequencyData(vc._analyserData); let sum = 0; for (let i = 0; i < vc._analyserData.length; i++) sum += vc._analyserData[i]; return sum / vc._analyserData.length; } // ── WebRTC Peer Connection (with SDP munging + data channels) ── function vcCreatePeer(username, displayName, initiator) { if (vc.peers[username]) return vc.peers[username]; const pc = new RTCPeerConnection(vc.iceConfig); const peer = {pc, audioEl: null, videoEl: null, tile: null, volume: 100, stream: null, displayName, dataChannel: null, iceCandidateBuffer: [], remoteDescriptionSet: false}; vc.peers[username] = peer; // Add local tracks if (vc.localStream) { vc.localStream.getTracks().forEach(t => pc.addTrack(t, vc.localStream)); } if (vc.screenStream) { vc.screenStream.getTracks().forEach(t => pc.addTrack(t, vc.screenStream)); } // Data channel for file transfer (initiator creates) if (initiator) { const dc = pc.createDataChannel('filetransfer', {ordered: true}); peer.dataChannel = dc; vcSetupDataChannel(dc, username); } pc.ondatachannel = (e) => { peer.dataChannel = e.channel; vcSetupDataChannel(e.channel, username); }; // ICE candidates → send via voice WS pc.onicecandidate = (e) => { if (e.candidate) { vcSend({type: 'ice', target: username, payload: e.candidate}); } else { console.log('[Voice] ICE gathering complete for', username); } }; pc.onicecandidateerror = (e) => { console.warn('[Voice] ICE error for', username, ':', e.errorCode, e.errorText); }; // Incoming tracks pc.ontrack = (e) => { const stream = e.streams[0] || new MediaStream([e.track]); peer.stream = stream; if (e.track.kind === 'audio') { if (!peer.audioEl) { peer.audioEl = document.createElement('audio'); peer.audioEl.autoplay = true; peer.audioEl.playsInline = true; if (peer.tile) peer.tile.appendChild(peer.audioEl); } peer.audioEl.srcObject = stream; peer.audioEl.volume = peer.volume / 100; } else if (e.track.kind === 'video') { vcShowVideoOnTile(username, stream); } }; pc.onconnectionstatechange = () => { const state = pc.connectionState; console.log('[Voice] Peer', username, 'connection:', state); if (state === 'failed' || state === 'disconnected') { console.warn(`Peer ${username} connection ${state}`); } else if (state === 'connected') { document.getElementById('vcStatusLabel').textContent = vc.callType === 'p2p' ? `Call with ${displayName}` : `Group call`; } }; // Add tile vcAddTile(username, displayName, false); // Create offer if initiator if (initiator) vcCreateOffer(username); return peer; } async function vcCreateOffer(username) { const peer = vc.peers[username]; if (!peer) return; try { console.log('[Voice] Creating offer for', username); const offer = await peer.pc.createOffer(); offer.sdp = vcMungeOpusSDP(offer.sdp); await peer.pc.setLocalDescription(offer); vcSend({type: 'offer', target: username, payload: peer.pc.localDescription}); console.log('[Voice] Offer sent to', username); } catch(e) { console.error('[Voice] Offer error:', e); } } async function vcHandleOffer(from, displayName, payload) { console.log('[Voice] Received offer from', from); let peer = vc.peers[from]; if (!peer) peer = vcCreatePeer(from, displayName, false); try { await peer.pc.setRemoteDescription(new RTCSessionDescription(payload)); peer.remoteDescriptionSet = true; for (const c of peer.iceCandidateBuffer) { try { await peer.pc.addIceCandidate(new RTCIceCandidate(c)); } catch(e) {} } peer.iceCandidateBuffer = []; const answer = await peer.pc.createAnswer(); answer.sdp = vcMungeOpusSDP(answer.sdp); await peer.pc.setLocalDescription(answer); vcSend({type: 'answer', target: from, payload: peer.pc.localDescription}); console.log('[Voice] Answer sent to', from); } catch(e) { console.error('[Voice] Answer error:', e); } } async function vcHandleAnswer(from, payload) { console.log('[Voice] Received answer from', from); const peer = vc.peers[from]; if (!peer) return; try { await peer.pc.setRemoteDescription(new RTCSessionDescription(payload)); peer.remoteDescriptionSet = true; for (const c of peer.iceCandidateBuffer) { try { await peer.pc.addIceCandidate(new RTCIceCandidate(c)); } catch(e) {} } peer.iceCandidateBuffer = []; console.log('[Voice] Answer processed, connection state:', peer.pc.connectionState); } catch(e) { console.error('[Voice] Answer error:', e); } } async function vcHandleIce(from, payload) { const peer = vc.peers[from]; if (!peer) return; // Buffer if remote description not set yet (race condition) if (!peer.remoteDescriptionSet) { peer.iceCandidateBuffer.push(payload); return; } try { await peer.pc.addIceCandidate(new RTCIceCandidate(payload)); } catch(e) { console.error('ICE error:', e); } } // ── Data Channel (File Transfer) ── function vcSetupDataChannel(dc, peerUsername) { dc.binaryType = 'arraybuffer'; dc.onmessage = (e) => { if (typeof e.data === 'string') { const msg = JSON.parse(e.data); if (msg.type === 'file_start') { vc.fileTransfers[msg.id] = {name: msg.name, size: msg.size, mime: msg.mime, chunks: [], received: 0, from: peerUsername}; const fp = document.getElementById('vcFileProgress'); document.getElementById('vcFileLabel').textContent = `Receiving: ${msg.name}`; document.getElementById('vcFileBar').style.width = '0%'; fp.style.display = 'block'; } else if (msg.type === 'file_end') { const ft = vc.fileTransfers[msg.id]; if (ft) { const blob = new Blob(ft.chunks, {type: ft.mime || 'application/octet-stream'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = ft.name; a.click(); URL.revokeObjectURL(url); delete vc.fileTransfers[msg.id]; document.getElementById('vcFileProgress').style.display = 'none'; } } } else { // Binary chunk — find the active transfer for (const [id, ft] of Object.entries(vc.fileTransfers)) { if (ft.from === peerUsername && ft.received < ft.size) { ft.chunks.push(e.data); ft.received += e.data.byteLength; const pct = Math.round((ft.received / ft.size) * 100); document.getElementById('vcFileBar').style.width = pct + '%'; break; } } } }; } function vcSendFile() { // Pick a file and send to the first peer (P2P) or let user choose const peerNames = Object.keys(vc.peers); if (peerNames.length === 0) return; const input = document.createElement('input'); input.type = 'file'; input.onchange = async () => { const file = input.files[0]; if (!file) return; const targetUser = peerNames.length === 1 ? peerNames[0] : prompt('Send to:', peerNames[0]); const peer = vc.peers[targetUser]; if (!peer || !peer.dataChannel || peer.dataChannel.readyState !== 'open') { alert('No data channel available to ' + targetUser); return; } const dc = peer.dataChannel; const transferId = Math.random().toString(36).slice(2, 10); dc.send(JSON.stringify({type: 'file_start', id: transferId, name: file.name, size: file.size, mime: file.type})); // Send in 16KB chunks const CHUNK = 16384; const fp = document.getElementById('vcFileProgress'); document.getElementById('vcFileLabel').textContent = `Sending: ${file.name}`; document.getElementById('vcFileBar').style.width = '0%'; fp.style.display = 'block'; let offset = 0; const reader = file.stream().getReader(); let buffer = new Uint8Array(0); while (true) { const {done, value} = await reader.read(); if (done && buffer.length === 0) break; if (value) { const combined = new Uint8Array(buffer.length + value.length); combined.set(buffer); combined.set(value, buffer.length); buffer = combined; } while (buffer.length >= CHUNK || (done && buffer.length > 0)) { const chunk = buffer.slice(0, CHUNK); buffer = buffer.slice(CHUNK); // Backpressure: wait if buffered amount is high while (dc.bufferedAmount > 1024 * 1024) { await new Promise(r => setTimeout(r, 50)); } dc.send(chunk); offset += chunk.length; document.getElementById('vcFileBar').style.width = Math.round((offset / file.size) * 100) + '%'; } if (done) break; } dc.send(JSON.stringify({type: 'file_end', id: transferId})); setTimeout(() => { fp.style.display = 'none'; }, 2000); }; input.click(); } // ── Tile Management ── function vcAddTile(username, displayName, isLocal) { const grid = document.getElementById('voiceGrid'); // Don't create duplicate tiles for same user if (grid.querySelector(`[data-user="${username}"]`)) return; const tile = document.createElement('div'); tile.className = 'vtile' + (isLocal ? ' local' : ''); tile.dataset.user = username; tile.draggable = !isLocal; const initial = (displayName || username || '?')[0].toUpperCase(); tile.innerHTML = `
${initial}
${displayName || username}
🎤
${!isLocal ? `
${currentUser && currentUser.permission_tier === 'admin' ? `` : ''}
` : `
You
`} `; // Drag-and-drop for reordering if (!isLocal) { tile.addEventListener('dragstart', (e) => { vc.dragState = username; tile.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); tile.addEventListener('dragend', () => { tile.classList.remove('dragging'); vc.dragState = null; document.querySelectorAll('.vtile.drag-over').forEach(t => t.classList.remove('drag-over')); }); tile.addEventListener('dragover', (e) => { e.preventDefault(); tile.classList.add('drag-over'); }); tile.addEventListener('dragleave', () => tile.classList.remove('drag-over')); tile.addEventListener('drop', (e) => { e.preventDefault(); tile.classList.remove('drag-over'); if (vc.dragState && vc.dragState !== username) { vcReorderTiles(vc.dragState, username); } }); } grid.appendChild(tile); if (isLocal) { // Show local video preview if cam is on if (vc.localStream) { // For now just avatar — cam toggle will add video } } else { if (vc.peers[username]) vc.peers[username].tile = tile; } if (!vc.tileOrder.includes(username)) vc.tileOrder.push(username); return tile; } function vcRemoveTile(username) { const grid = document.getElementById('voiceGrid'); const tile = grid.querySelector(`.vtile[data-user="${username}"]`); if (tile) tile.remove(); vc.tileOrder = vc.tileOrder.filter(u => u !== username); } function vcShowVideoOnTile(username, stream) { const grid = document.getElementById('voiceGrid'); const tile = grid.querySelector(`.vtile[data-user="${username}"]`); if (!tile) return; let video = tile.querySelector('video'); if (!video) { video = document.createElement('video'); video.autoplay = true; video.playsInline = true; video.muted = (username === '__local__'); tile.insertBefore(video, tile.firstChild); // Hide avatar when video is showing const avatar = tile.querySelector('.vtile-avatar'); if (avatar) avatar.style.display = 'none'; } video.srcObject = stream; } function vcHideVideoOnTile(username) { const grid = document.getElementById('voiceGrid'); const tile = grid.querySelector(`.vtile[data-user="${username}"]`); if (!tile) return; const video = tile.querySelector('video'); if (video) video.remove(); const avatar = tile.querySelector('.vtile-avatar'); if (avatar) avatar.style.display = ''; } function vcReorderTiles(fromUser, toUser) { const fromIdx = vc.tileOrder.indexOf(fromUser); const toIdx = vc.tileOrder.indexOf(toUser); if (fromIdx < 0 || toIdx < 0) return; vc.tileOrder.splice(fromIdx, 1); vc.tileOrder.splice(toIdx, 0, fromUser); vcRenderTileOrder(); } function vcRenderTileOrder() { const grid = document.getElementById('voiceGrid'); // Move tiles to match order — local tile always first const localTile = grid.querySelector('.vtile[data-user="__local__"]'); if (localTile) grid.insertBefore(localTile, grid.firstChild); for (const user of vc.tileOrder) { if (user === '__local__') continue; const tile = grid.querySelector(`.vtile[data-user="${user}"]`); if (tile) grid.appendChild(tile); } } // ── Per-Peer Controls ── function vcSetPeerVolume(username, val) { const peer = vc.peers[username]; if (peer) { peer.volume = parseInt(val); if (peer.audioEl) peer.audioEl.volume = peer.volume / 100; } } function vcTogglePeerMute(username) { const peer = vc.peers[username]; if (!peer || !peer.audioEl) return; peer.audioEl.muted = !peer.audioEl.muted; } function vcPinUser(username) { if (vc.pinnedUser === username) { vc.pinnedUser = null; document.querySelectorAll('.vtile.pinned').forEach(t => t.classList.remove('pinned')); } else { vc.pinnedUser = username; document.querySelectorAll('.vtile.pinned').forEach(t => t.classList.remove('pinned')); const tile = document.querySelector(`.vtile[data-user="${username}"]`); if (tile) tile.classList.add('pinned'); } vcApplyLayout(); } function vcAdminMute(username) { vcSend({type: 'admin_mute', target: username, mute_audio: true}); } // ── Self Controls ── function vcToggleMic() { vc.micMuted = !vc.micMuted; if (vc.localStream) { vc.localStream.getAudioTracks().forEach(t => { t.enabled = !vc.micMuted; }); } vcUpdateToolbar(); vcSendMediaState(); } async function vcToggleCam() { if (vc.camOn) { if (vc.localStream) { vc.localStream.getVideoTracks().forEach(t => { t.stop(); vc.localStream.removeTrack(t); }); } vc.camOn = false; vcHideVideoOnTile('__local__'); for (const peer of Object.values(vc.peers)) { if (peer.pc) { peer.pc.getSenders().forEach(s => { if (s.track && s.track.kind === 'video' && !vc.screenOn) { peer.pc.removeTrack(s); } }); } } } else { try { const videoDeviceId = document.getElementById('vsVideoInput')?.value; const constraints = {video: videoDeviceId ? {deviceId: {exact: videoDeviceId}} : true}; const camStream = await navigator.mediaDevices.getUserMedia(constraints); const videoTrack = camStream.getVideoTracks()[0]; if (vc.localStream) { vc.localStream.addTrack(videoTrack); } else { vc.localStream = camStream; } vc.camOn = true; vcShowVideoOnTile('__local__', new MediaStream([videoTrack])); for (const peer of Object.values(vc.peers)) { if (peer.pc) { peer.pc.addTrack(videoTrack, vc.localStream); vcCreateOffer(Object.keys(vc.peers).find(k => vc.peers[k] === peer)); } } } catch(e) { console.error('Camera error:', e); return; } } vcUpdateToolbar(); vcSendMediaState(); } async function vcToggleScreen() { if (vc.screenOn) { if (vc.screenStream) { vc.screenStream.getTracks().forEach(t => t.stop()); vc.screenStream = null; } vc.screenOn = false; for (const [user, peer] of Object.entries(vc.peers)) { if (peer.pc) { peer.pc.getSenders().forEach(s => { if (s.track && s.track.label && s.track.label.includes('screen')) { peer.pc.removeTrack(s); } }); vcCreateOffer(user); } } } else { try { vc.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true, systemAudio: 'include', }); vc.screenOn = true; vcShowVideoOnTile('__local__', vc.screenStream); for (const [user, peer] of Object.entries(vc.peers)) { if (peer.pc) { vc.screenStream.getTracks().forEach(t => peer.pc.addTrack(t, vc.screenStream)); vcCreateOffer(user); } } vc.screenStream.getVideoTracks()[0].onended = () => { vc.screenOn = true; vcToggleScreen(); }; } catch(e) { console.error('Screen share error:', e); return; } } vcUpdateToolbar(); vcSendMediaState(); } function vcCyclePttMode() { const modes = ['always', 'ptt', 'vad']; const idx = modes.indexOf(vc.pttMode); vc.pttMode = modes[(idx + 1) % modes.length]; vcUpdateToolbar(); vcSendMediaState(); if (vc.pttMode === 'ptt') { if (vc.localStream) vc.localStream.getAudioTracks().forEach(t => { t.enabled = false; }); } else if (vc.pttMode === 'always') { if (vc.localStream && !vc.micMuted) vc.localStream.getAudioTracks().forEach(t => { t.enabled = true; }); } } // PTT keyboard handler document.addEventListener('keydown', (e) => { if (!vc.inCall || vc.pttMode !== 'ptt' || e.repeat) return; if (e.code === 'Space' && document.activeElement.tagName !== 'TEXTAREA' && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); vc.pttActive = true; if (vc.localStream && !vc.micMuted) vc.localStream.getAudioTracks().forEach(t => { t.enabled = true; }); vcSend({type: 'ptt', speaking: true}); } }); document.addEventListener('keyup', (e) => { if (!vc.inCall || vc.pttMode !== 'ptt') return; if (e.code === 'Space' && vc.pttActive) { vc.pttActive = false; if (vc.localStream) vc.localStream.getAudioTracks().forEach(t => { t.enabled = false; }); vcSend({type: 'ptt', speaking: false}); } }); function vcSendMediaState() { vcSend({ type: 'media_state', audio: !vc.micMuted, video: vc.camOn, screen: vc.screenOn, ptt_mode: vc.pttMode, }); } // ── Layout ── function vcSetLayout(layout) { vc.layout = layout; const grid = document.getElementById('voiceGrid'); grid.className = 'voice-grid layout-' + layout; // Update settings picker document.querySelectorAll('#voiceSettings .layout-picker')[0]?.querySelectorAll('button').forEach(b => { b.classList.toggle('active', b.textContent.toLowerCase() === layout); }); vcApplyLayout(); } function vcCycleLayout() { const layouts = ['grid', 'speaker', 'sidebar']; const idx = layouts.indexOf(vc.layout); vcSetLayout(layouts[(idx + 1) % layouts.length]); } function vcApplyLayout() { const grid = document.getElementById('voiceGrid'); if (vc.layout === 'speaker' && vc.pinnedUser) { // Move pinned user's tile to first position const pinnedTile = grid.querySelector(`.vtile[data-user="${vc.pinnedUser}"]`); if (pinnedTile) grid.insertBefore(pinnedTile, grid.firstChild); } } // ── Panel Mode ── function vcSetPanelMode(mode) { const panel = document.getElementById('voicePanel'); panel.classList.remove('docked', 'floating', 'expanded', 'pip'); panel.classList.add(mode); vc.panelMode = mode; // Update settings picker const pickers = document.querySelectorAll('#voiceSettings .layout-picker'); if (pickers[1]) pickers[1].querySelectorAll('button').forEach(b => { b.classList.toggle('active', b.textContent.toLowerCase() === mode || (mode === 'pip' && b.textContent === 'PiP')); }); // Make PiP draggable if (mode === 'pip' || mode === 'floating') { vcMakeDraggablePanel(panel); } } function vcCycleMode() { const modes = ['docked', 'floating', 'expanded', 'pip']; const idx = modes.indexOf(vc.panelMode); vcSetPanelMode(modes[(idx + 1) % modes.length]); } function vcMakeDraggablePanel(panel) { let isDragging = false, startX, startY, startLeft, startTop; const toolbar = panel.querySelector('.voice-toolbar'); toolbar.style.cursor = 'move'; toolbar.onmousedown = (e) => { if (e.target.closest('.vt-btn')) return; // Don't drag on buttons isDragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; e.preventDefault(); }; document.addEventListener('mousemove', (e) => { if (!isDragging) return; panel.style.left = (startLeft + e.clientX - startX) + 'px'; panel.style.top = (startTop + e.clientY - startY) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { isDragging = false; }); } // ── Resize Handle ── (function() { const handle = document.getElementById('voiceResizeHandle'); if (!handle) return; let resizing = false, startY, startH; handle.addEventListener('mousedown', (e) => { const panel = document.getElementById('voicePanel'); if (vc.panelMode !== 'docked') return; resizing = true; startY = e.clientY; startH = panel.offsetHeight; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!resizing) return; const panel = document.getElementById('voicePanel'); const newH = Math.max(120, Math.min(window.innerHeight * 0.8, startH - (e.clientY - startY))); panel.style.height = newH + 'px'; }); document.addEventListener('mouseup', () => { resizing = false; }); })(); // ── Settings ── function vcToggleSettings() { document.getElementById('voiceSettings').classList.toggle('open'); } function vcPopulateDevices(devices) { const audioIn = document.getElementById('vsAudioInput'); const videoIn = document.getElementById('vsVideoInput'); audioIn.innerHTML = ''; videoIn.innerHTML = ''; devices.filter(d => d.kind === 'audioinput').forEach(d => { audioIn.innerHTML += ``; }); devices.filter(d => d.kind === 'videoinput').forEach(d => { videoIn.innerHTML += ``; }); } async function vcChangeAudioInput() { if (!vc.inCall) return; const deviceId = document.getElementById('vsAudioInput').value; try { const newStream = await navigator.mediaDevices.getUserMedia({ audio: {deviceId: {exact: deviceId}, echoCancellation: true, noiseSuppression: true} }); const newTrack = newStream.getAudioTracks()[0]; // Replace track on all peer connections for (const peer of Object.values(vc.peers)) { if (peer.pc) { const sender = peer.pc.getSenders().find(s => s.track && s.track.kind === 'audio'); if (sender) await sender.replaceTrack(newTrack); } } // Replace on local stream if (vc.localStream) { vc.localStream.getAudioTracks().forEach(t => { t.stop(); vc.localStream.removeTrack(t); }); vc.localStream.addTrack(newTrack); } } catch(e) { console.error('Audio input switch error:', e); } } async function vcChangeVideoInput() { if (!vc.inCall || !vc.camOn) return; const deviceId = document.getElementById('vsVideoInput').value; try { const newStream = await navigator.mediaDevices.getUserMedia({ video: {deviceId: {exact: deviceId}} }); const newTrack = newStream.getVideoTracks()[0]; for (const peer of Object.values(vc.peers)) { if (peer.pc) { const sender = peer.pc.getSenders().find(s => s.track && s.track.kind === 'video'); if (sender) await sender.replaceTrack(newTrack); } } if (vc.localStream) { vc.localStream.getVideoTracks().forEach(t => { t.stop(); vc.localStream.removeTrack(t); }); vc.localStream.addTrack(newTrack); } vcShowVideoOnTile('__local__', new MediaStream([newTrack])); } catch(e) { console.error('Video input switch error:', e); } } function vcSetInputGain(val) { if (vc.gainNode) vc.gainNode.gain.value = val / 100; document.getElementById('vsInputGainVal').textContent = val + '%'; } function vcSetAudioSource(src) { vc.audioSource = src; // This would require re-acquiring the stream // For 'system' — need getDisplayMedia, for 'both' — mix mic + system } function vcToggleNoiseGate() { vc.noiseGateEnabled = document.getElementById('vsNoiseGate').checked; } function vcSetNoiseThreshold(val) { vc.noiseThreshold = parseInt(val); document.getElementById('vsNoiseThreshVal').textContent = val + 'dB'; } // ── Toolbar State ── function vcUpdateToolbar() { const micBtn = document.getElementById('vcMicBtn'); const camBtn = document.getElementById('vcCamBtn'); const screenBtn = document.getElementById('vcScreenBtn'); const pttBtn = document.getElementById('vcPttBtn'); micBtn.classList.toggle('active', !vc.micMuted); micBtn.style.opacity = vc.micMuted ? '0.4' : '1'; micBtn.title = vc.micMuted ? 'Unmute mic' : 'Mute mic'; camBtn.classList.toggle('active', vc.camOn); camBtn.title = vc.camOn ? 'Turn off camera' : 'Turn on camera'; screenBtn.classList.toggle('active', vc.screenOn); screenBtn.title = vc.screenOn ? 'Stop sharing' : 'Share screen'; const pttLabels = {always: 'Always on', ptt: 'Push-to-talk (Space)', vad: 'Voice activated'}; pttBtn.title = 'Voice mode: ' + pttLabels[vc.pttMode]; pttBtn.classList.toggle('active', vc.pttMode !== 'always'); } // ── Speaking Detection Animation (only runs during active calls) ── let _speakingDetectTimer = null; function vcStartSpeakingDetect() { if (_speakingDetectTimer) return; _speakingDetectTimer = setInterval(() => { if (!vc.inCall) { vcStopSpeakingDetect(); return; } const level = vcGetInputLevel(); const localTile = document.querySelector('.vtile[data-user="__local__"]'); if (localTile) localTile.classList.toggle('speaking', level > 15 && !vc.micMuted); }, 100); } function vcStopSpeakingDetect() { if (_speakingDetectTimer) { clearInterval(_speakingDetectTimer); _speakingDetectTimer = null; } } // ── Voice WS Message Handler ── function vcHandleMessage(data) { const myUsername = currentUser?.username; switch (data.type) { case 'voice_presence': vc.voiceOnline = data.online || []; vc.voiceInCalls = data.in_calls || {}; // Update online dropdown if open if (onlineDropdownOpen) renderOnlineDropdown(); break; case 'incoming_call': vcShowIncoming(data); break; case 'call_ringing': vc.callId = data.call_id; vc.inCall = true; vc.callType = 'p2p'; break; case 'call_connected': { console.log('[Voice] call_connected:', data.call_id, 'joined:', data.joined, 'I am:', myUsername); vc.callId = data.call_id; vc.inCall = true; vc.callType = data.call_type; vc.incomingCall = null; vcShowPanel(); // Connect to all other participants const participants = data.participants || {}; for (const [peerUser, peerState] of Object.entries(participants)) { if (peerUser === myUsername) continue; if (!vc.peers[peerUser]) { const iAmJoiner = data.joined === myUsername; console.log('[Voice] Creating peer for', peerUser, 'iAmJoiner:', iAmJoiner); vcCreatePeer(peerUser, peerState.display_name || peerUser, iAmJoiner); } vcUpdatePeerIndicators(peerUser, peerState); } document.getElementById('vcStatusLabel').textContent = data.call_type === 'p2p' ? 'Connected' : `Group call (${Object.keys(participants).length})`; // Update join button const joinBtn = document.getElementById('voiceJoinBtn'); if (joinBtn) { joinBtn.classList.add('active'); joinBtn.style.background = 'var(--accent)'; joinBtn.style.color = 'var(--bg)'; } break; } case 'call_rejected': vcCleanupCall(); break; case 'call_ended': vcCleanupCall(); break; case 'peer_left': { const peer = vc.peers[data.username]; if (peer) { if (peer.pc) peer.pc.close(); if (peer.audioEl) peer.audioEl.remove(); } delete vc.peers[data.username]; vcRemoveTile(data.username); // Update participant count const remaining = data.participants ? Object.keys(data.participants).length : 0; document.getElementById('vcStatusLabel').textContent = `Group call (${remaining})`; break; } case 'call_error': console.warn('Call error:', data.error); if (!vc.inCall || Object.keys(vc.peers).length === 0) vcCleanupCall(); break; case 'offer': if (vc.inCall) vcHandleOffer(data.from, data.display_name, data.payload); break; case 'answer': if (vc.inCall) vcHandleAnswer(data.from, data.payload); break; case 'ice': if (vc.inCall) vcHandleIce(data.from, data.payload); break; case 'media_state': vcUpdatePeerIndicators(data.username, data); break; case 'ptt': vcUpdateSpeaking(data.username, data.speaking); break; case 'admin_mute': if (vc.localStream) { vc.localStream.getAudioTracks().forEach(t => { t.enabled = false; }); vc.micMuted = true; vcUpdateToolbar(); } break; case 'file_offer': // Show file offer notification if (confirm(`${data.display_name || data.from} wants to send: ${data.filename} (${(data.size/1024).toFixed(1)}KB)`)) { vcSend({type: 'file_accept', target: data.from}); } break; case 'pong': break; } } // Legacy room voice handler — redirects to global voice system function vcHandleRoomMessage(data) { // Room voice messages are now handled by the global voice WS // This stub keeps the room WS handler from erroring return false; } function vcUpdatePeerIndicators(username, state) { if (!state) return; const tile = document.querySelector(`.vtile[data-user="${username}"]`); if (!tile) return; const indicators = tile.querySelector('.vtile-indicators'); if (!indicators) return; let html = ''; if (state.audio === false) html += '🔇'; else html += '🎤'; if (state.video) html += '🎥'; if (state.screen) html += '💻'; indicators.innerHTML = html; } function vcUpdateSpeaking(username, speaking) { const tile = document.querySelector(`.vtile[data-user="${username}"]`); if (tile) tile.classList.toggle('speaking', speaking); } function vcUpdateVoiceCount(allPeers) { const btn = document.getElementById('voiceJoinBtn'); if (!btn) return; const count = allPeers ? Object.keys(allPeers).length : 0; if (count > 0) { btn.innerHTML = `📞 ${count}`; btn.style.position = 'relative'; } else { btn.innerHTML = '📞'; } } // Hook into the existing room message handler const _origHandleRoomMessage = handleRoomMessage; handleRoomMessage = function(data) { // Voice messages now handled by global voice WS — pass through if (vcHandleRoomMessage(data)) return; _origHandleRoomMessage(data); }; // ── Init ──────────────────────────────────── // Purge stale pending messages on every page load — prevents phantom sends (function() { const pending = JSON.parse(localStorage.getItem(PENDING_MSG_KEY) || '[]'); const now = Date.now(); const fresh = pending.filter(m => now - m.ts < 2 * 60 * 1000); if (fresh.length !== pending.length) { console.log('[INIT] Purged', pending.length - fresh.length, 'stale pending messages'); localStorage.setItem(PENDING_MSG_KEY, JSON.stringify(fresh)); } })(); // Check auth first, then load sessions + connect voice WS (async function() { const authed = await checkAuth(); if (authed) { renderSessionTotals(); loadModels(); // populate model dropdown (Claude + Ollama) loadSessions(); // Connect global voice WebSocket vcConnectWs(); // Notification permission requested on first user click (line 9420), not here // Mobile browsers crash or show blocking dialogs if requestPermission fires without gesture } else { // Sessions will load after login } })();