// 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'; import { state } from './state.js'; export const MODE = Object.freeze({ DIRECTORY: 'directory', DOCUMENTS: 'documents', GENERAL: 'general', }); // File extensions recognised when turning a mention into a clickable link. // Anything else that looks like a path is treated as a directory (revealed // in the sidebar tree instead of being opened in the viewer). const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i; // A path-like token: optional `dir/` segments followed by a final segment. const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g; /** * 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; this._sessions = []; this._currentSessionId = null; this._pendingNewSession = false; // Agent mode: routes chat through /api/ai/bookslm/agent so the model can // call read/search tools and propose mutations (confirmation cards). this._agentMode = this._readAgentMode(); } _readAgentMode() { try { return localStorage.getItem('obsigate-bookslm-agent') === 'true'; } catch { return false; } } _toggleAgentMode() { this._agentMode = !this._agentMode; try { localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false'); } catch { /* private mode */ } this._updateAgentToggle(); } _updateAgentToggle() { if (!this._panel) return; const btn = this._panel.querySelector('.bookslm-btn-agent'); if (!btn) return; btn.classList.toggle('active', this._agentMode); const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off'); btn.title = label; btn.setAttribute('aria-label', label); btn.setAttribute('aria-pressed', this._agentMode ? 'true' : '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(); this._closeHistoryMenu(); this._renderHistoryMenu(); 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() { // Called before the panel exists (command palette): defer the fresh // session until the assistant opens for its actual context. if (!this._panel) { this._pendingNewSession = true; this._messages = []; return; } const session = this._createSession([]); this._sessions.unshift(session); this._currentSessionId = session.id; this._messages = session.messages; this._persistSessions(); this._renderMessages(); this._showSuggestions(); this._renderHistoryMenu(); } exportConversation() { if (!this._messages.length) return; const current = this._currentSession(); const label = (current && current.title) || 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) { return this._openFileLink(path); } /** Resolve the vault used to open/reveal a link mentioned by the assistant. */ _resolveVault() { if (this._vault) return this._vault; try { const docs = collectOpenDocuments(); if (docs.length) return docs[0].vault; } catch { /* not ready */ } return state.currentVault || null; } /** Open a file mentioned in an assistant answer in the main viewer. */ async _openFileLink(path) { const vault = this._resolveVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; } window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault, path } })); try { const m = await import('./viewer.js'); if (m && typeof m.openFile === 'function') await m.openFile(vault, path); } catch (e) { console.warn('AI assistant: open file link failed', e); } // Reposition the tree indicator on the opened file (best-effort). try { const s = await import('./sidebar.js'); if (s && typeof s.focusPathInSidebar === 'function') { await s.focusPathInSidebar(vault, path, { alignToTop: true }); } } catch (e) { console.warn('AI assistant: reveal file in tree failed', e); } } /** Reveal a directory (or file) path in the sidebar tree. */ async _revealPath(path) { const vault = this._resolveVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; } window.dispatchEvent(new CustomEvent('obsigate:reveal-in-tree', { detail: { vault, path } })); try { const m = await import('./sidebar.js'); if (m && typeof m.focusPathInSidebar === 'function') { await m.focusPathInSidebar(vault, path, { alignToTop: true, expandTarget: true }); } } catch (e) { console.warn('AI assistant: reveal path failed', e); } } // ── 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-agent').addEventListener('click', () => this._toggleAgentMode()); panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => { e.stopPropagation(); this._toggleHistoryMenu(); }); panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation()); panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar()); // Clicking a file/path link inside an assistant answer either opens the // file in the viewer or reveals the path in the sidebar tree. panel.querySelector('.bookslm-messages').addEventListener('click', (e) => { const link = e.target.closest('a.bookslm-path-link'); if (!link) return; e.preventDefault(); const path = link.getAttribute('data-path'); if (!path) return; if (link.getAttribute('data-kind') === 'dir') this._revealPath(path); else this._openFileLink(path); }); // Close the session menu when clicking elsewhere in the panel. panel.addEventListener('click', (e) => { if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) { this._closeHistoryMenu(); } }); 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 (msg.toolCalls && msg.toolCalls.length) { bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild); } 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.confirmation) { bubble.appendChild(this._renderConfirmationCard(msg)); } 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 */ } } // ── Tool activity & confirmations (agent mode) ────────────────────── _renderToolActivity(toolCalls) { const wrap = document.createElement('div'); wrap.className = 'bookslm-tool-trace'; for (const call of toolCalls) { const line = document.createElement('div'); line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); line.textContent = `${call.ok === false ? '⚠' : '🔧'} ${t('ai.tool_call', { name: call.name })}`; if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name }); wrap.appendChild(line); } return wrap; } _renderConfirmationCard(msg) { const conf = msg.confirmation; const pending = conf.pending || {}; const error = pending.error || pending; const tool = error.tool || 'action'; const args = error.arguments || {}; const card = document.createElement('div'); card.className = 'bookslm-action bookslm-confirm'; const meta = document.createElement('div'); meta.className = 'bookslm-action-meta'; meta.innerHTML = '🔒'; const textEl = document.createElement('span'); textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : ''); meta.appendChild(textEl); card.appendChild(meta); const diffHost = document.createElement('div'); diffHost.className = 'bookslm-confirm-diff'; if (conf._diffHtml) { diffHost.innerHTML = conf._diffHtml; } else if (!conf._diffLoading) { conf._diffLoading = true; this._fillConfirmationDiff(args, conf).then(() => this._renderMessages()); } card.appendChild(diffHost); 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._applyConfirmation(msg); 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 _fillConfirmationDiff(args, conf) { const proposed = args.content; if (typeof proposed !== 'string' || !args.vault || !args.path) return; let current = null; try { current = await this._fetchCurrentContent(args.vault, args.path); } catch { current = null; } const title = `
${this._escapeHtml(t('ai.diff_preview'))}
`; if (current == null) { // New file (or unreadable): show everything as an addition. conf._diffHtml = title + `
${this._renderDiffLines([{ t: '+', s: proposed }])}
`; return; } const lines = this._lineDiff(current, proposed); conf._diffHtml = title + `
${this._renderDiffLines(lines)}
`; } async _fetchCurrentContent(vault, path) { const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`); return data && typeof data.raw === 'string' ? data.raw : ''; } /** Minimal line diff (LCS) between the current and the proposed content. */ _lineDiff(oldText, newText) { const a = String(oldText || '').split('\n'); const b = String(newText || '').split('\n'); const n = a.length; const m = b.length; const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } const out = []; let i = 0; let j = 0; while (i < n && j < m) { if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; } else { out.push({ t: '+', s: b[j] }); j++; } } while (i < n) out.push({ t: '-', s: a[i++] }); while (j < m) out.push({ t: '+', s: b[j++] }); return out; } _renderDiffLines(lines) { const cap = 400; const shown = lines.length > cap ? lines.slice(0, cap) : lines; let html = shown.map((l) => { const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx'); return `${this._escapeHtml(l.t + ' ' + l.s)}`; }).join('\n'); if (lines.length > cap) { html += `\n… ${lines.length - cap}`; } return html; } async _applyConfirmation(msg) { const conf = msg.confirmation; if (!conf || conf._applying) return; conf._applying = true; const payload = { ...(msg.payload || {}), confirm: conf.pending, confirm_messages: conf.messages, }; this._isLoading = true; this._abortCtrl = new AbortController(); const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send'); if (sendBtn) sendBtn.disabled = true; const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null }; try { let resp = await this._postChat(payload); if (resp.status === 401 && AuthManager._authEnabled) { await AuthManager.refreshAccessToken(); resp = await this._postChat(payload); } if (!resp.ok) { let detail = `HTTP ${resp.status}`; try { detail = (await resp.json()).detail || detail; } catch { /* */ } throw new Error(detail); } // The confirmation is resolved: drop the card and show the continuation. msg.confirmation = null; this._messages.push(continuation); await this._streamResponse(resp, continuation, payload); } finally { conf._applying = false; this._isLoading = false; this._abortCtrl = null; if (sendBtn) sendBtn.disabled = false; this._renderMessages(); this._saveHistory(); } } // ── 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(); 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 assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload }; 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 resp = await this._postChat(payload); if (resp.status === 401 && AuthManager._authEnabled) { await AuthManager.refreshAccessToken(); resp = await this._postChat(payload); } if (!resp.ok) { let detail = `HTTP ${resp.status}`; try { detail = (await resp.json()).detail || detail; } catch { /* */ } throw new Error(detail); } await this._streamResponse(resp, assistantMsg, payload); 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(); } /** POST to /chat or /agent depending on the agent-mode toggle. */ _postChat(payload) { const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat'; const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; return fetch(endpoint, { method: 'POST', headers, credentials: 'include', body: JSON.stringify(payload), signal: this._abortCtrl ? this._abortCtrl.signal : undefined, }); } /** * Consume an SSE response, feeding tokens/tool/confirmation events into * `assistantMsg`. Shared by the initial send and the confirmation resume. */ async _streamResponse(resp, assistantMsg, payload) { 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 === 'tool') { assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} }); return; } if (currentEvent === 'confirmation') { assistantMsg.confirmation = { pending: data.pending || data, messages: data.messages || [], }; 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); } // ── Markdown (self-contained renderer) ────────────────────────────── // // Renders the assistant answer as formatted HTML (headings, lists, tables, // blockquotes, code, emphasis) without any external dependency. All model // text is HTML-escaped before markup is produced, and file/path mentions // become clickable links handled by the delegated listener in `_render()`. _renderMarkdown(text) { if (!text) return ''; const lines = String(text).replace(/\r\n?/g, '\n').split('\n'); const out = []; const para = []; let i = 0; const flushPara = () => { if (!para.length) return; out.push(`

${this._renderInline(para.join('\n'))}

`); para.length = 0; }; while (i < lines.length) { const line = lines[i]; // Fenced code block. const fence = line.match(/^\s*```\s*([\w+#-]*)\s*$/); if (fence) { flushPara(); const lang = fence[1] || ''; const code = []; i++; while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i++; } i++; // closing fence out.push(this._renderCodeBlock(lang, code.join('\n'))); continue; } // ATX heading. const heading = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/); if (heading) { flushPara(); const level = heading[1].length; out.push(`${this._renderInline(heading[2])}`); i++; continue; } // Horizontal rule. if (/^\s*(?:[-*_])\s*(?:[-*_]\s*){2,}$/.test(line)) { flushPara(); out.push('
'); i++; continue; } // Blockquote (may contain any block content). if (/^\s*>\s?/.test(line)) { flushPara(); const quote = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^\s*>\s?/, '')); i++; } out.push(`
${this._renderMarkdown(quote.join('\n'))}
`); continue; } // Table. if (line.includes('|') && i + 1 < lines.length && this._isTableSeparator(lines[i + 1])) { flushPara(); const table = this._renderTable(lines, i); out.push(table.html); i = table.next; continue; } // List. if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { flushPara(); const list = this._renderList(lines, i); out.push(list.html); i = list.next; continue; } if (!line.trim()) { flushPara(); i++; continue; } para.push(line); i++; } flushPara(); return out.join('\n'); } /** Render the inline (span-level) markup of a single line or paragraph. */ _renderInline(text) { if (text == null) return ''; const stash = []; const put = (html) => { const idx = stash.length; stash.push(html); return `\u0000${idx}\u0000`; }; let s = this._escapeHtml(String(text)); // Inline code — a path wrapped in backticks becomes a clickable link. s = s.replace(/`([^`\n]+)`/g, (_m, code) => { const raw = this._decodeEntities(code); if (this._looksLikePath(raw)) return put(`${this._pathLink(code, raw)}`); return put(`${code}`); }); // Explicit markdown links: [label](target). s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) => put(this._renderMdLink(label, target))); // Bare `dir/file.ext` mentions. s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) => `${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`); // Emphasis. s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); s = s.replace(/__([^_\n]+)__/g, '$1'); s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2'); s = s.replace(/~~([^~\n]+)~~/g, '$1'); // Preserve single line breaks (chat answers often rely on them). s = s.replace(/\n/g, '
'); // Restore stashed fragments. s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => stash[Number(idx)] || ''); return s; } _renderCodeBlock(lang, code) { const cls = lang ? ` class="language-${this._escapeAttr(lang)}"` : ''; return `
${this._escapeHtml(code)}
`; } _isTableSeparator(line) { if (!line || !line.includes('|') || !line.includes('-')) return false; return line.replace(/[|\s:-]/g, '') === ''; } _renderTable(lines, start) { const splitRow = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); const header = splitRow(lines[start]); const rows = []; let i = start + 2; while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(splitRow(lines[i])); i++; } const thead = header.map((c) => `${this._renderInline(c)}`).join(''); const tbody = rows .map((r) => `${r.map((c) => `${this._renderInline(c)}`).join('')}`) .join(''); return { html: `${thead}${tbody}
`, next: i }; } _renderList(lines, start) { const first = lines[start].match(/^(\s*)([-*+]|\d+\.)\s+/); const baseIndent = first ? first[1].length : 0; const ordered = first ? /\d+\./.test(first[2]) : false; const items = []; let i = start; while (i < lines.length) { const line = lines[i]; if (!line.trim()) break; const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/); if (!m) break; const indent = m[1].length; if (indent < baseIndent) break; if (indent > baseIndent) { const nested = this._renderList(lines, i); if (items.length) items[items.length - 1].children.push(nested.html); i = nested.next; continue; } let content = m[3]; let checkbox = ''; const task = content.match(/^\[([ xX])\]\s+(.*)$/); if (task) { checkbox = ` `; content = task[2]; } items.push({ html: checkbox + this._renderInline(content), children: [] }); i++; } const tag = ordered ? 'ol' : 'ul'; const body = items.map((it) => `
  • ${it.html}${it.children.join('')}
  • `).join(''); return { html: `<${tag}>${body}`, next: i }; } _renderMdLink(label, target) { const isExternal = /^(?:https?:|mailto:|tel:|#|\/\/)/i.test(target); if (isExternal) { return `${label}`; } const raw = this._decodeEntities(target).replace(/^\.\//, ''); return this._pathLink(label, raw); } /** Build a clickable link for a vault-relative file or directory path. */ _pathLink(display, rawPath) { const clean = String(rawPath || '').replace(/^\.\//, ''); const kind = this._classifyPath(clean); const attr = this._escapeAttr(clean); return `${display}`; } _classifyPath(path) { return FILE_EXT_RE.test(path || '') ? 'file' : 'dir'; } _looksLikePath(value) { if (!value) return false; const p = value.trim(); if (!p || /\s/.test(p)) return false; if (!/^[\w./\\-]+$/.test(p)) return false; return p.includes('/') || FILE_EXT_RE.test(p); } _escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } _escapeAttr(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } _decodeEntities(s) { return String(s) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); } // ── Session history ───────────────────────────────────────────────── // // Each context (directory / documents / general) owns a list of sessions // persisted in localStorage. A session is `{id, title, createdAt, // updatedAt, messages}`; the header history button lists them so a previous // conversation can be reopened or deleted. _contextKey() { const ctx = this._mode === MODE.DOCUMENTS ? documentsSignature(this._documents) : (this._directory || this._vault || 'general'); return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; } _sessionsKey() { return `bookslm-sessions-${this._contextKey()}`; } _legacyHistoryKey() { return `bookslm-history-${this._contextKey()}`; } _newSessionId() { return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } _createSession(messages) { const now = Date.now(); return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] }; } _currentSession() { return this._sessions.find((s) => s.id === this._currentSessionId) || null; } _readSessions() { try { const raw = localStorage.getItem(this._sessionsKey()); if (!raw) return []; const list = JSON.parse(raw); if (!Array.isArray(list)) return []; return list .filter((s) => s && typeof s === 'object' && Array.isArray(s.messages)) .map((s) => ({ id: s.id || this._newSessionId(), title: s.title || '', createdAt: s.createdAt || Date.now(), updatedAt: s.updatedAt || s.createdAt || Date.now(), messages: s.messages, })); } catch { return []; } } /** Import the pre-sessions single conversation, if any. */ _migrateLegacyHistory() { try { const raw = localStorage.getItem(this._legacyHistoryKey()); if (!raw) return null; const messages = JSON.parse(raw); if (!Array.isArray(messages) || !messages.length) return null; const session = this._createSession(messages); session.title = this._deriveTitle(messages); return session; } catch { return null; } } _loadHistory() { this._sessions = this._readSessions(); if (!this._sessions.length) { const migrated = this._migrateLegacyHistory(); if (migrated) this._sessions = [migrated]; } if (this._pendingNewSession) { // A "new conversation" was requested before the panel existed // (command palette): start a fresh session on top of the existing ones. this._pendingNewSession = false; this._sessions.unshift(this._createSession([])); this._persistSessions(); } else if (!this._sessions.length) { this._sessions = [this._createSession([])]; } this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); const current = this._sessions[0]; this._currentSessionId = current.id; this._messages = Array.isArray(current.messages) ? current.messages : []; } _persistSessions() { try { localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions)); } catch { /* quota / private mode */ } } /** Strip transient (large) fields before persisting a conversation. */ _persistableMessages() { return this._messages.map(({ confirmation, payload, ...rest }) => rest); } _saveHistory() { const persisted = this._persistableMessages(); const session = this._currentSession(); if (session) { session.messages = persisted; session.updatedAt = Date.now(); if (!session.title) session.title = this._deriveTitle(this._messages); } else { const created = this._createSession(persisted); created.title = this._deriveTitle(this._messages); this._sessions.unshift(created); this._currentSessionId = created.id; } this._persistSessions(); } _deriveTitle(messages) { const firstUser = (messages || []).find((m) => m && m.role === 'user' && m.content && m.content.trim()); if (!firstUser) return ''; const line = firstUser.content.trim().split('\n')[0]; return line.length > 60 ? line.slice(0, 57) + '…' : line; } _loadSession(id) { const session = this._sessions.find((s) => s.id === id); if (!session) return; this._currentSessionId = id; this._messages = Array.isArray(session.messages) ? session.messages : []; this._closeHistoryMenu(); if (this._panel) { this._renderMessages(); this._showSuggestions(); this._renderHistoryMenu(); } } _deleteSession(id) { this._sessions = this._sessions.filter((s) => s.id !== id); if (!this._sessions.length) { const session = this._createSession([]); this._sessions.push(session); this._currentSessionId = session.id; this._messages = session.messages; } else if (id === this._currentSessionId) { const next = this._sessions[0]; this._currentSessionId = next.id; this._messages = next.messages || []; } this._persistSessions(); if (this._panel) { this._renderMessages(); this._showSuggestions(); this._renderHistoryMenu(); } } _toggleHistoryMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-history-menu'); if (!menu) return; const nowHidden = menu.classList.toggle('hidden'); if (!nowHidden) this._renderHistoryMenu(); } _closeHistoryMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-history-menu'); if (menu) menu.classList.add('hidden'); } _renderHistoryMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-history-menu'); if (!menu) return; menu.innerHTML = ''; const sessions = [...this._sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); if (!sessions.length) { const empty = document.createElement('div'); empty.className = 'bookslm-history-empty'; empty.textContent = t('bookslm.history_empty'); menu.appendChild(empty); return; } for (const session of sessions) { const item = document.createElement('div'); item.className = 'bookslm-history-item'; if (session.id === this._currentSessionId) item.classList.add('active'); const open = document.createElement('button'); open.type = 'button'; open.className = 'bookslm-history-open'; const title = document.createElement('span'); title.className = 'bookslm-history-title'; title.textContent = session.title || t('bookslm.untitled'); const date = document.createElement('span'); date.className = 'bookslm-history-date'; date.textContent = this._formatDate(session.updatedAt); open.appendChild(title); open.appendChild(date); open.addEventListener('click', () => this._loadSession(session.id)); const del = document.createElement('button'); del.type = 'button'; del.className = 'bookslm-history-delete'; del.title = t('bookslm.session_delete'); del.setAttribute('aria-label', t('bookslm.session_delete')); del.innerHTML = ''; del.addEventListener('click', (e) => { e.stopPropagation(); this._deleteSession(session.id); }); item.appendChild(open); item.appendChild(del); menu.appendChild(item); } if (typeof safeCreateIcons === 'function') safeCreateIcons(); } _formatDate(ts) { if (!ts) return ''; try { return new Date(ts).toLocaleString(); } catch { return ''; } } } const booksLM = new BooksLM(); export default booksLM; export { BooksLM };