// ObsiGate — AI Assistant sidebar. // // The panel adapts to three contexts: // • directory — a vault folder (opened from the tree context menu) // • documents — the file(s) currently open in tabs/panes // • general — no document open: app help + file/directory creation actions import { t } from './i18n.js'; import { buildAIPickerUI } from './ai.js'; import { safeCreateIcons } from './utils.js'; import { api, AuthManager } from './auth.js'; import { showToast } from './ui.js'; export const MODE = Object.freeze({ DIRECTORY: 'directory', DOCUMENTS: 'documents', GENERAL: 'general', }); /** * Gather the files currently open across the singleton TabManager and every * split-view pane (window globals avoid a circular import with ui.js). * The active tab of each pane is listed first. */ export function collectOpenDocuments() { const docs = []; const seen = new Set(); const add = (vault, path) => { if (!vault || !path) return; const key = `${vault}\u0000${path}`; if (seen.has(key)) return; seen.add(key); docs.push({ vault, path }); }; const scan = (tm) => { if (!tm || !Array.isArray(tm._tabs)) return; const active = tm._tabs.find((x) => x.id === tm._activeTabId); if (active) add(active.vault, active.path); for (const tab of tm._tabs) add(tab.vault, tab.path); }; try { scan(window.TabManager); const pm = window.PaneManager; if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan); } catch { /* not ready yet */ } return docs; } export function documentsSignature(documents) { return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000'); } class BooksLM { constructor() { this._isOpen = false; this._mode = MODE.GENERAL; this._vault = null; this._directory = null; this._documents = []; this._effectiveScope = MODE.GENERAL; this._messages = []; this._contextFiles = []; this._isLoading = false; this._abortCtrl = null; this._panel = null; this._isFullscreen = false; } // ── Public API ────────────────────────────────────────────────────── /** * Open the assistant for a specific context. * @param {object} opts * @param {'directory'|'documents'|'general'} opts.mode * @param {string|null} [opts.vault] * @param {string} [opts.directory] * @param {Array<{vault:string,path:string}>} [opts.documents] */ async openContext(opts = {}) { const mode = opts.mode || MODE.GENERAL; const vault = opts.vault || null; const directory = opts.directory || ''; const documents = Array.isArray(opts.documents) ? opts.documents : []; const sameContext = this._isOpen && this._mode === mode && this._vault === vault && this._directory === directory && documentsSignature(this._documents) === documentsSignature(documents); if (sameContext) { // Already open for this context: reveal a collapsed panel, else close. if (this._panel && this._panel.classList.contains('hidden')) { this._setHidden(false); document.dispatchEvent(new CustomEvent('bookslm:opened')); } else { this.close(); } return; } this._mode = mode; this._vault = vault; this._directory = directory; this._documents = documents; this._effectiveScope = mode; this._messages = []; this._contextFiles = []; this._isLoading = true; this._loadHistory(); if (!this._panel) { this._panel = this._render(); document.body.appendChild(this._panel); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } // An explicit open always reveals the panel, even if it was collapsed // in a previous session (previously it stayed hidden → "ne s'ouvre plus"). this._setHidden(false); this._updateHeader(); this._updateStatus(); this._renderMessages(); this._showSuggestions(); requestAnimationFrame(() => { this._panel.classList.add('open'); document.dispatchEvent(new CustomEvent('bookslm:opened', { detail: { mode, vault, directory, documents }, })); }); this._isOpen = true; await this._loadContext(); this._isLoading = false; } /** Backwards-compatible helper: open the assistant on a directory. */ async open(vault, directory) { return this.openContext({ mode: MODE.DIRECTORY, vault, directory }); } /** * Open the assistant using the current application context: the open * document(s) if any, otherwise the General assistant. */ async openForCurrentContext() { const docs = collectOpenDocuments(); if (docs.length) { const vault = docs[0].vault; const scoped = docs.filter((d) => d.vault === vault); return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped }); } return this.openContext({ mode: MODE.GENERAL }); } close() { if (!this._panel || !this._isOpen) return; this._panel.classList.remove('open'); this._isOpen = false; document.dispatchEvent(new CustomEvent('bookslm:closed')); if (this._abortCtrl) { this._abortCtrl.abort(); this._abortCtrl = null; } } _setHidden(hidden) { if (!this._panel) return; this._panel.classList.toggle('hidden', hidden); try { localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false'); } catch { /* */ } this._updateToggleIcon(); } /** Collapse/expand while keeping the panel mounted (header toggle button). */ _toggleSidebar() { if (!this._panel) return; const isHidden = !this._panel.classList.contains('hidden'); this._setHidden(isHidden); document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened')); } _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(); } } exportConversation() { if (!this._messages.length) return; const label = this._contextTitle() || 'assistant'; let md = `# ${label}\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 = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`; a.click(); URL.revokeObjectURL(a.href); } openFile(path) { window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault: this._vault, path }, })); } // ── Context ───────────────────────────────────────────────────────── async _loadContext() { if (this._mode === MODE.GENERAL) { this._effectiveScope = MODE.GENERAL; this._contextFiles = []; this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 }); this._showSuggestions(); return; } const payload = { mode: this._mode, vault: this._vault, directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], }; try { const data = await api('/api/ai/bookslm/context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); this._contextFiles = data.files || []; this._effectiveScope = data.scope || this._mode; this._updateStatus(data); this._showSuggestions(); } catch (e) { console.warn('AI assistant: context load failed', e); this._contextFiles = []; this._updateStatus({ error: e.message }); } } _contextMeta() { if (this._mode === MODE.DOCUMENTS) { const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean); return { icon: 'file-text', title: t('ai.context_documents', { count: this._documents.length }), subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''), }; } if (this._mode === MODE.DIRECTORY) { const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault; return { icon: 'folder', title: name || this._vault || t('bookslm.title'), subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`, }; } return { icon: 'sparkles', title: t('ai.context_general'), subtitle: t('ai.context_general_desc'), }; } _contextTitle() { return this._contextMeta().title; } // ── Rendering ─────────────────────────────────────────────────────── _render() { const panel = document.createElement('div'); panel.className = 'bookslm-panel'; panel.innerHTML = `
${t('ai.model_settings')}
${t('ai.input_hint')}
`; // Provider/model picker lives on its own toolbar row so the header // actions stay visible regardless of the provider/model text width. const pickerHost = panel.querySelector('.bookslm-picker-host'); buildAIPickerUI().then((picker) => { if (!picker || !pickerHost) return; pickerHost.replaceWith(picker); if (typeof safeCreateIcons === 'function') safeCreateIcons(); }).catch(() => { /* ignore */ }); 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); const icon = panel.querySelector('.bookslm-btn-fullscreen i'); if (icon) { icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2'); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } }); panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage()); const textarea = panel.querySelector('textarea'); textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px'; }); textarea.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; if (e.ctrlKey || e.metaKey) { // Ctrl/Cmd+Enter → insert a newline at the caret. e.preventDefault(); const start = textarea.selectionStart; const end = textarea.selectionEnd; textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end); textarea.selectionStart = textarea.selectionEnd = start + 1; textarea.dispatchEvent(new Event('input')); return; } // Enter (with or without Shift) → submit. Shift+Enter also submits so // the documented shortcut (Ctrl+Enter) is the only way to add a line. e.preventDefault(); this._sendMessage(); }); return panel; } _updateHeader() { if (!this._panel) return; const meta = this._contextMeta(); const title = this._panel.querySelector('.bookslm-title'); const subtitle = this._panel.querySelector('.bookslm-subtitle'); const icon = this._panel.querySelector('.bookslm-context-icon i'); if (title) title.textContent = meta.title; if (subtitle) subtitle.textContent = meta.subtitle || ''; if (icon) icon.setAttribute('data-lucide', meta.icon); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } _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 && Array.isArray(data.files)) { const count = data.files.length; const chars = data.total_chars || 0; const maxChars = data.max_total_chars || 200000; const pct = Math.min(100, Math.round((chars / maxChars) * 100)); const label = data.scope === MODE.GENERAL ? t('ai.context_general_desc') : `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`; status.innerHTML = ` ${label}
`; } else if (this._isLoading) { status.textContent = '⏳ …'; } } _suggestionsForContext() { if (this._mode === MODE.GENERAL) { return [ t('ai.suggestion_capabilities'), t('ai.suggestion_search'), t('ai.suggestion_create_file'), ]; } if (this._mode === MODE.DOCUMENTS) { return [ t('ai.suggestion_docs_summary'), t('ai.suggestion_docs_keypoints'), t('ai.suggestion_docs_contradictions'), ]; } return [ t('bookslm.suggestion_summary'), t('bookslm.suggestion_themes'), t('bookslm.suggestion_contradictions'), ]; } _showSuggestions() { if (!this._panel) return; const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (!sugEl) return; sugEl.innerHTML = ''; if (this._messages.length) return; for (const s of this._suggestionsForContext()) { 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') { const { text, actions } = this._extractActions(msg.content || ''); bubble.innerHTML = this._renderMarkdown(text); if (actions.length) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'bookslm-actions'; actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action))); bubble.appendChild(actionsDiv); } 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); } container.scrollTop = container.scrollHeight; } // ── Actions (general assistant) ───────────────────────────────────── _extractActions(text) { const actions = []; const re = /```obsigate-action\s*\n([\s\S]*?)```/g; let match; while ((match = re.exec(text)) !== null) { try { const obj = JSON.parse(match[1].trim()); if (obj && typeof obj.action === 'string') actions.push(obj); } catch { /* malformed block — ignore */ } } return { text: text.replace(re, '').trim(), actions }; } _renderActionCard(action) { const card = document.createElement('div'); card.className = 'bookslm-action'; const isDir = action.action === 'create_directory'; const label = isDir ? t('ai.action_create_dir', { path: action.path || '' }) : t('ai.action_create_file', { path: action.path || '' }); const meta = document.createElement('div'); meta.className = 'bookslm-action-meta'; meta.innerHTML = `${isDir ? '📁' : '📄'}`; const textEl = document.createElement('span'); textEl.textContent = label; meta.appendChild(textEl); card.appendChild(meta); const apply = document.createElement('button'); apply.className = 'bookslm-action-apply'; apply.textContent = t('ai.action_apply'); apply.addEventListener('click', async () => { apply.disabled = true; apply.textContent = t('ai.action_applying'); try { await this._applyAction(action); card.classList.add('done'); apply.textContent = t('ai.action_applied'); } catch (e) { apply.disabled = false; apply.textContent = t('ai.action_apply'); showToast(t('ai.action_failed', { error: e.message }), 'error'); } }); card.appendChild(apply); return card; } async _applyAction(action) { if (!action.vault || !action.path) throw new Error('missing vault/path'); if (action.action === 'create_file') { await api(`/api/file/${encodeURIComponent(action.vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: action.path, content: action.content || '' }), }); showToast(t('ai.action_created_file', { path: action.path }), 'success'); } else if (action.action === 'create_directory') { await api(`/api/directory/${encodeURIComponent(action.vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: action.path }), }); showToast(t('ai.action_created_dir', { path: action.path }), 'success'); } else { throw new Error(`unknown action: ${action.action}`); } try { const m = await import('./sidebar.js'); if (m && typeof m.refreshSidebarTreePreservingState === 'function') { await m.refreshSidebarTreePreservingState(); } } catch { /* tree refresh is best-effort */ } } // ── 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'; const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (sugEl) sugEl.innerHTML = ''; // Snapshot prior turns BEFORE appending the new user message. const history = this._messages .filter((m) => m.content && m.content.trim()) .map((m) => ({ role: m.role, content: m.content })); this._messages.push({ role: 'user', content: text }); this._renderMessages(); const assistantMsg = { role: 'assistant', content: '', sources: [] }; this._messages.push(assistantMsg); this._renderMessages(); this._isLoading = true; const sendBtn = this._panel.querySelector('.bookslm-btn-send'); if (sendBtn) sendBtn.disabled = true; this._abortCtrl = new AbortController(); try { 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 payload = { mode: this._mode, vault: this._vault, directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], message: text, conversation_history: history, provider, model, }; const doFetch = () => { const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; return fetch('/api/ai/bookslm/chat', { method: 'POST', headers, credentials: 'include', body: JSON.stringify(payload), signal: this._abortCtrl.signal, }); }; let resp = await doFetch(); if (resp.status === 401 && AuthManager._authEnabled) { await AuthManager.refreshAccessToken(); resp = await doFetch(); } if (!resp.ok) { let detail = `HTTP ${resp.status}`; try { detail = (await resp.json()).detail || detail; } catch { /* */ } throw new Error(detail); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentEvent = 'message'; const handleLine = (line) => { if (line.startsWith('event:')) { currentEvent = line.slice(6).trim(); return; } if (!line.startsWith('data:')) return; const raw = line.slice(5).trim(); if (!raw || raw === '[DONE]') return; let data; try { data = JSON.parse(raw); } catch { assistantMsg.content += raw; return; } if (currentEvent === 'error' || data.error) { const msg = data.error || t('bookslm.error'); assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg; return; } const chunk = data.token || data.content || ''; if (chunk) assistantMsg.content += chunk; if (data.sources) assistantMsg.sources = data.sources; }; 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) handleLine(line); this._renderMessages(); } if (buffer) handleLine(buffer); if (!assistantMsg.sources.length && this._contextFiles.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('AI assistant 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 ''; // Escape HTML first so model output cannot inject markup. let html = text .replace(/&/g, '&') .replace(//g, '>') .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') .replace(/^[*\-+] (.+)$/gm, '
  • $1
  • ') .replace(/^### (.+)$/gm, '

    $1

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

    $1

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

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

    ${html}

    `; } // ── History persistence ───────────────────────────────────────────── _historyKey() { const ctx = this._mode === MODE.DOCUMENTS ? documentsSignature(this._documents) : (this._directory || this._vault || 'general'); return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; } _saveHistory() { try { localStorage.setItem(this._historyKey(), JSON.stringify(this._messages)); } catch { /* */ } } _loadHistory() { try { const data = localStorage.getItem(this._historyKey()); this._messages = data ? JSON.parse(data) : []; } catch { this._messages = []; } } } const booksLM = new BooksLM(); export default booksLM; export { BooksLM };