CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 48s
CI / build (push) Successful in 21s
CI / e2e (push) Successful in 6m4s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
493 lines
16 KiB
JavaScript
493 lines
16 KiB
JavaScript
// 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 = `
|
|
<div class="bookslm-header">
|
|
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
|
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
|
</button>
|
|
<span>📚</span>
|
|
<span class="bookslm-title"></span>
|
|
<span class="bookslm-picker-host" style="margin-left:auto"></span>
|
|
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
|
|
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
|
|
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
|
|
<button class="bookslm-btn-close" title="✕">✕</button>
|
|
</div>
|
|
<div class="bookslm-status"></div>
|
|
<div class="bookslm-suggestions"></div>
|
|
<div class="bookslm-messages"></div>
|
|
<div class="bookslm-input-area">
|
|
<textarea placeholder="${t('bookslm.placeholder')}" rows="1"></textarea>
|
|
<button class="bookslm-btn-send">${t('bookslm.send')}</button>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `<span style="color:#f87171">⚠ ${t('bookslm.no_context')}</span>`;
|
|
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 = `
|
|
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
|
|
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
|
`;
|
|
} 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, '<pre><code>$2</code></pre>')
|
|
// Inline code
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
// Bold
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
// Italic
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
// Links
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
// Unordered lists
|
|
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
|
// Headers
|
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
|
// Paragraphs
|
|
.replace(/\n\n/g, '</p><p>')
|
|
.replace(/\n/g, '<br>');
|
|
return `<p>${html}</p>`;
|
|
}
|
|
|
|
// ── 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 };
|