// BooksLM — Directory-scoped AI chat panel (style NotebookLM) import { t } from './i18n.js'; import { buildAIPickerUI } from './ai.js'; import { safeCreateIcons } from './utils.js'; class BooksLM { constructor() { this._isOpen = false; this._vault = null; this._directory = null; this._messages = []; this._contextFiles = []; this._isLoading = false; this._abortCtrl = null; this._panel = null; this._isFullscreen = false; } // ── Public API ────────────────────────────────────────────────────── async open(vault, directory) { if (this._isOpen && this._vault === vault && this._directory === directory) { this.close(); return; } this._vault = vault; this._directory = directory; this._messages = []; this._contextFiles = []; this._isLoading = true; // Restore history this._loadHistory(); // Build panel if needed if (!this._panel) { this._panel = this._render(); document.body.appendChild(this._panel); } this._updateHeader(); this._updateStatus(); this._renderMessages(); // Open with animation requestAnimationFrame(() => { this._panel.classList.add('open'); let hidden = false; // Apply persisted hidden state (user toggled off in a previous session) try { const savedHidden = localStorage.getItem('obsigate-bookslm-hidden'); if (savedHidden === 'true') { this._panel.classList.add('hidden'); this._updateToggleIcon(); hidden = true; } } catch { /* */ } document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } })); }); this._isOpen = true; // Load context try { const resp = await fetch('/api/ai/bookslm/context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vault, directory }) }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); this._contextFiles = data.files || []; this._updateStatus(data); this._showSuggestions(); } catch (e) { console.warn('BooksLM: context load failed', e); this._contextFiles = []; this._updateStatus({ error: e.message }); } this._isLoading = false; } close() { if (!this._panel || !this._isOpen) return; this._panel.classList.remove('open'); this._isOpen = false; document.dispatchEvent(new CustomEvent('bookslm:closed')); // Abort any in-flight request if (this._abortCtrl) { this._abortCtrl.abort(); this._abortCtrl = null; } } /** * Toggle the BooksLM sidebar visibility (collapsed/hidden to the right edge * while the panel stays mounted). The state is persisted in localStorage so * the user choice survives page reloads — same UX as the left sidebar toggle. * * The panel stays in the DOM (no teardown); only a CSS transform is applied, * which is what makes this fast and matches the right-sidebar pattern in * ObsiGate. */ _toggleSidebar() { if (!this._panel) return; const isHidden = this._panel.classList.toggle('hidden'); try { localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false'); } catch { /* */ } this._updateToggleIcon(); document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened')); } /** * Swap the toggle icon between "panel-right-close" (visible) and * "panel-right-open" (hidden) so the button visually reflects the state. */ _updateToggleIcon() { if (!this._panel) return; const btn = this._panel.querySelector('.bookslm-btn-toggle'); if (!btn) return; const isHidden = this._panel.classList.contains('hidden'); const icon = btn.querySelector('i[data-lucide]'); if (!icon) return; icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close'); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } newConversation() { this._messages = []; this._saveHistory(); if (this._panel) { this._renderMessages(); this._showSuggestions(); } if (!this._isOpen && this._vault && this._directory) { this.open(this._vault, this._directory); } } exportConversation() { if (!this._messages.length) return; let md = `# BooksLM — ${this._directory}\n\n`; for (const msg of this._messages) { const role = msg.role === 'user' ? '**You**' : '**AI**'; md += `### ${role}\n\n${msg.content}\n\n---\n\n`; } const blob = new Blob([md], { type: 'text/markdown' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `bookslm-${this._directory.replace(/\//g, '_')}.md`; a.click(); URL.revokeObjectURL(a.href); } openFile(path) { window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault: this._vault, path } })); } // ── Rendering ─────────────────────────────────────────────────────── _render() { const panel = document.createElement('div'); panel.className = 'bookslm-panel'; panel.innerHTML = `
📚
`; // Inject the provider/model picker asynchronously (depends on /api/ai/status) const pickerHost = panel.querySelector('.bookslm-picker-host'); buildAIPickerUI().then((picker) => { if (picker && pickerHost) pickerHost.replaceWith(picker); }).catch(() => { /* ignore */ }); // Wire events panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close()); panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation()); panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation()); panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar()); panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => { this._isFullscreen = !this._isFullscreen; panel.classList.toggle('fullscreen', this._isFullscreen); }); // Send button panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage()); // Textarea auto-grow + Ctrl+Enter const textarea = panel.querySelector('textarea'); textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; }); textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this._sendMessage(); } }); return panel; } _updateHeader() { if (!this._panel) return; const title = this._panel.querySelector('.bookslm-title'); if (title) { title.textContent = this._directory ? `📚 ${this._directory.split('/').pop() || this._vault}` : t('bookslm.title'); } } _updateStatus(data) { if (!this._panel) return; const status = this._panel.querySelector('.bookslm-status'); if (!status) return; if (data && data.error) { status.innerHTML = `⚠ ${t('bookslm.no_context')}`; return; } if (data && data.files) { const count = data.files.length; const chars = data.total_chars || 0; const pct = Math.min(100, Math.round((chars / 100000) * 100)); status.innerHTML = ` ${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}
`; } else if (this._isLoading) { status.textContent = '⏳ ...'; } } _showSuggestions() { if (!this._panel) return; const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (!sugEl) return; sugEl.innerHTML = ''; if (!this._contextFiles.length && !this._isLoading) return; const suggestions = [ t('bookslm.suggestion_summary'), t('bookslm.suggestion_themes'), t('bookslm.suggestion_contradictions') ]; for (const s of suggestions) { const btn = document.createElement('button'); btn.className = 'bookslm-suggestion'; btn.textContent = s; btn.addEventListener('click', () => { const textarea = this._panel.querySelector('textarea'); if (textarea) { textarea.value = s; this._sendMessage(); } }); sugEl.appendChild(btn); } } _renderMessages() { if (!this._panel) return; const container = this._panel.querySelector('.bookslm-messages'); if (!container) return; container.innerHTML = ''; for (const msg of this._messages) { const bubble = document.createElement('div'); bubble.className = `bookslm-bubble ${msg.role}`; if (msg.role === 'assistant') { bubble.innerHTML = this._renderMarkdown(msg.content || ''); // Source badges if (msg.sources && msg.sources.length) { const sourcesDiv = document.createElement('div'); sourcesDiv.className = 'bookslm-sources'; for (const src of msg.sources) { const badge = document.createElement('span'); badge.className = 'bookslm-source-badge'; badge.textContent = `📄 ${src.split('/').pop()}`; badge.title = src; badge.addEventListener('click', () => this.openFile(src)); sourcesDiv.appendChild(badge); } bubble.appendChild(sourcesDiv); } } else { bubble.textContent = msg.content; } container.appendChild(bubble); } // Scroll to bottom container.scrollTop = container.scrollHeight; } // ── Messaging ─────────────────────────────────────────────────────── async _sendMessage() { const textarea = this._panel.querySelector('textarea'); const text = (textarea.value || '').trim(); if (!text || this._isLoading) return; textarea.value = ''; textarea.style.height = 'auto'; // Hide suggestions const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (sugEl) sugEl.innerHTML = ''; // Add user message this._messages.push({ role: 'user', content: text }); this._renderMessages(); // Add assistant placeholder const assistantMsg = { role: 'assistant', content: '', sources: [] }; this._messages.push(assistantMsg); this._renderMessages(); this._isLoading = true; // Disable send button const sendBtn = this._panel.querySelector('.bookslm-btn-send'); if (sendBtn) sendBtn.disabled = true; this._abortCtrl = new AbortController(); try { // Read provider/model from the picker (localStorage-backed) let provider = null; let model = null; try { const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); provider = picker.provider || null; model = picker.model || null; } catch { /* */ } const resp = await fetch('/api/ai/bookslm/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vault: this._vault, directory: this._directory, // The backend expects `message` + `conversation_history`, // not a generic `messages` array. Convert before sending. message: this._messages.length ? this._messages[this._messages.length - 1].content : '', conversation_history: this._messages .slice(0, -1) .filter((m) => m.content && m.content.trim()) .map((m) => ({ role: m.role, content: m.content })), context_files: this._contextFiles.map((f) => f.path || f), provider, model, }), signal: this._abortCtrl.signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const payload = line.slice(6); if (payload === '[DONE]') continue; try { const data = JSON.parse(payload); if (data.content) { assistantMsg.content += data.content; } if (data.sources) { assistantMsg.sources = data.sources; } } catch { // Raw text token assistantMsg.content += payload; } this._renderMessages(); } } // Extract sources from context files if mentioned if (!assistantMsg.sources.length) { const contentLower = assistantMsg.content.toLowerCase(); assistantMsg.sources = this._contextFiles .filter(f => { const name = (f.path || f || '').split('/').pop().toLowerCase(); return name && contentLower.includes(name); }) .map(f => f.path || f); } } catch (e) { if (e.name !== 'AbortError') { assistantMsg.content = `⚠ Error: ${e.message}`; console.warn('BooksLM chat error:', e); } } this._isLoading = false; if (sendBtn) sendBtn.disabled = false; this._abortCtrl = null; this._renderMessages(); this._saveHistory(); } // ── Markdown (lightweight) ────────────────────────────────────────── _renderMarkdown(text) { if (!text) return ''; let html = text // Code blocks .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') // Inline code .replace(/`([^`]+)`/g, '$1') // Bold .replace(/\*\*(.+?)\*\*/g, '$1') // Italic .replace(/\*(.+?)\*/g, '$1') // Links .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // Unordered lists .replace(/^[*\-+] (.+)$/gm, '
  • $1
  • ') // Headers .replace(/^### (.+)$/gm, '

    $1

    ') .replace(/^## (.+)$/gm, '

    $1

    ') // Paragraphs .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    '); return `

    ${html}

    `; } // ── History persistence ───────────────────────────────────────────── _historyKey() { return `bookslm-history-${this._vault}-${this._directory}`; } _saveHistory() { try { localStorage.setItem(this._historyKey(), JSON.stringify(this._messages)); } catch {} } _loadHistory() { try { const data = localStorage.getItem(this._historyKey()); if (data) { this._messages = JSON.parse(data); } } catch { this._messages = []; } } } const booksLM = new BooksLM(); export default booksLM; export { BooksLM };