// 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, getModelCapabilities } 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; // Image files that can be attached to a vision-capable model. const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i; // Resizable panel bounds (px). const PANEL_MIN_WIDTH = 320; const PANEL_MAX_WIDTH = 1000; const PANEL_WIDTH_KEY = 'obsigate-bookslm-width'; /** * Accent- and case-insensitive normalization used to match `/` commands and * `@` mentions: skill ids/labels and vault paths may contain accented * characters (é, à, ç…), which the composer regexes and filters must accept. */ function normalizeSearch(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); } /** * 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(); // Ad-hoc context added with `@` (files/directories) and images attached // by paste or by mentioning an image file. this._adhocFiles = []; this._adhocDirs = []; this._images = []; this._activeSkill = null; this._skills = []; this._menuItems = []; this._menuIndex = 0; this._menuKind = null; // Monotonic token used to drop stale async menu renders (fast typing). this._menuSeq = 0; // Timer that hides the "done" activity indicator after a short delay. this._activityHideTimer = null; // Cache of every indexed path per vault, backing instant `@` filtering. this._pathCache = null; this._pathCacheVault = null; this._pathCachePromise = null; } _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._adhocFiles = []; this._adhocDirs = []; this._images = []; this._activeSkill = null; this._isLoading = true; this._loadHistory(); if (!this._panel) { this._panel = this._render(); document.body.appendChild(this._panel); this._applyPanelWidth(); 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._renderAttachments(); 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; // Warm the `@` path cache so the mention menu appears instantly. this._ensurePaths().catch(() => { /* best effort */ }); } /** 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; } /** * Vault used for context/chat requests. Falls back to the vault of the first * ad-hoc `@` entry: in General mode there is no base vault, but the selected * files/directories still belong to a vault that must be sent to the server. */ _contextVault() { if (this._vault) return this._vault; const entry = this._adhocFiles[0] || this._adhocDirs[0] || this._images[0]; if (entry && entry.vault) return entry.vault; return this._resolveVault(); } /** * Concrete vault used by the assistant when none is explicitly resolved: * falls back to the sidebar's selected context vault, then the first * available vault. Used to browse `@` files and to open/reveal links. */ _activeVault() { const resolved = this._contextVault(); if (resolved) return resolved; const selected = state.selectedContextVault; if (selected && selected !== 'all') return selected; const first = Array.isArray(state.allVaults) ? state.allVaults[0] : null; return (first && (first.name || first)) || null; } /** * Fetch (once per vault) the flat list of indexed paths so the `@` menu can * filter files/directories instantly on the client. Falls back to an empty * list on failure; callers then use the tree-search endpoint. */ async _ensurePaths() { const vault = this._activeVault(); if (!vault) return []; if (this._pathCache && this._pathCacheVault === vault) return this._pathCache; if (this._pathCachePromise && this._pathCacheVault === vault) return this._pathCachePromise; this._pathCacheVault = vault; this._pathCachePromise = api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`) .then((data) => { const paths = Array.isArray(data.results) ? data.results : []; this._pathCache = paths; return paths; }) .catch(() => { this._pathCache = []; return []; }) .finally(() => { this._pathCachePromise = null; }); return this._pathCachePromise; } /** Open a file mentioned in an assistant answer in the main viewer. */ async _openFileLink(path) { const vault = this._activeVault(); 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._activeVault(); 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() { const hasAdhoc = this._adhocFiles.length > 0 || this._adhocDirs.length > 0; if (this._mode === MODE.GENERAL && !hasAdhoc) { 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._contextVault(), directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], extra_files: this._adhocFiles.map((f) => f.path), extra_directories: this._adhocDirs.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'), // The description is kept for the tooltip only, to keep the header clean. subtitle: '', tooltip: t('ai.context_general_desc'), }; } _contextTitle() { return this._contextMeta().title; } // ── Rendering ─────────────────────────────────────────────────────── _render() { const panel = document.createElement('div'); panel.className = 'bookslm-panel'; panel.innerHTML = `
`; // 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 / command menus 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(); } if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) { this._hideMenus(); } }); 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()); // Resizable panel (drag the left edge). const resizeHandle = panel.querySelector('.bookslm-resize-handle'); if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e)); // Image attachment button (pick image files from disk). const attachBtn = panel.querySelector('.bookslm-btn-attach'); const fileInput = panel.querySelector('.bookslm-file-input'); if (attachBtn && fileInput) { attachBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => { this._addImageFiles(fileInput.files); fileInput.value = ''; }); } const textarea = panel.querySelector('textarea'); textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px'; this._onComposerInput(); }); textarea.addEventListener('paste', (e) => this._onPaste(e)); // Menu navigation is handled at the panel level (capture phase) so the // arrow keys keep working even if focus is not exactly on the textarea // (e.g. after interacting with the menu). It runs before the textarea // handler and stops propagation when it consumes the event. panel.addEventListener('keydown', (e) => { if (!this._menuKind) return; const isComposer = e.target && e.target.tagName === 'TEXTAREA'; const isNav = e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape'; const isPick = e.key === 'Enter' && isComposer && !e.ctrlKey && !e.metaKey; if ((isNav || isPick) && this._handleMenuKey(e)) { e.stopPropagation(); } }, true); textarea.addEventListener('keydown', (e) => { // Menu navigation (mention/command) takes precedence over submit. if (this._handleMenuKey(e)) return; 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; } // ── Panel resize ──────────────────────────────────────────────────── _applyPanelWidth() { if (!this._panel) return; let width = 0; try { width = parseInt(localStorage.getItem(PANEL_WIDTH_KEY) || '', 10) || 0; } catch { /* */ } if (width) this._panel.style.width = `${this._clampPanelWidth(width)}px`; } _clampPanelWidth(width) { return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width)); } _startResize(e) { if (!this._panel || this._isFullscreen) return; e.preventDefault(); const startX = e.clientX; const startWidth = this._panel.getBoundingClientRect().width; const onMove = (ev) => { // Panel is anchored to the right edge: dragging left widens it. const next = this._clampPanelWidth(startWidth + (startX - ev.clientX)); this._panel.style.width = `${next}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.classList.remove('bookslm-resizing'); try { localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(this._panel.getBoundingClientRect().width))); } catch { /* */ } }; document.body.classList.add('bookslm-resizing'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } // ── Attachments: ad-hoc context chips & images ────────────────────── _renderAttachments() { if (!this._panel) return; const host = this._panel.querySelector('.bookslm-attachments'); if (!host) return; host.innerHTML = ''; const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill; host.classList.toggle('empty', !hasAny); if (!hasAny) return; if (this._activeSkill) { const skill = this._skillById(this._activeSkill); host.appendChild(this._chip( `${skill ? skill.icon : '✨'} ${skill ? skill.label : this._activeSkill}`, 'skill', () => { this._activeSkill = null; this._renderAttachments(); }, )); } for (const file of this._adhocFiles) { host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path))); } for (const dir of this._adhocDirs) { host.appendChild(this._chip(`📁 ${dir.path}`, 'dir', () => this._removeAdhocDir(dir.path))); } this._images.forEach((img, index) => { const chip = this._chip(`🖼 ${img.name || t('ai.image')}`, 'image', () => { this._images.splice(index, 1); this._renderAttachments(); }); if (img.previewUrl) { const thumb = document.createElement('img'); thumb.className = 'bookslm-chip-thumb'; thumb.src = img.previewUrl; chip.insertBefore(thumb, chip.firstChild); } host.appendChild(chip); }); } _chip(label, kind, onRemove) { const chip = document.createElement('span'); chip.className = `bookslm-chip bookslm-chip-${kind}`; const text = document.createElement('span'); text.className = 'bookslm-chip-label'; text.textContent = label; text.title = label; chip.appendChild(text); const close = document.createElement('button'); close.type = 'button'; close.className = 'bookslm-chip-remove'; close.setAttribute('aria-label', t('ai.remove')); close.textContent = '×'; close.addEventListener('click', onRemove); chip.appendChild(close); return chip; } _addAdhocFile(path, vault) { if (!path || this._adhocFiles.some((f) => f.path === path)) return; this._adhocFiles.push({ path, vault: vault || null }); this._renderAttachments(); this._loadContext(); } _addAdhocDir(path, vault) { const clean = path.replace(/\/+$/, ''); if (!clean || this._adhocDirs.some((d) => d.path === clean)) return; this._adhocDirs.push({ path: clean, vault: vault || null }); this._renderAttachments(); this._loadContext(); } _removeAdhocFile(path) { this._adhocFiles = this._adhocFiles.filter((f) => f.path !== path); this._renderAttachments(); this._loadContext(); } _removeAdhocDir(path) { this._adhocDirs = this._adhocDirs.filter((d) => d.path !== path); this._renderAttachments(); this._loadContext(); } _addImageFile(file) { if (!file || !file.type || !file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = () => { const result = String(reader.result || ''); const match = result.match(/^data:([^;,]+);base64,(.*)$/); if (!match) return; this._images.push({ name: file.name || t('ai.image'), mime_type: match[1], data: match[2], previewUrl: result, }); this._renderAttachments(); }; reader.readAsDataURL(file); } _addImageFiles(files) { if (!files) return; Array.from(files).forEach((file) => this._addImageFile(file)); } _onPaste(e) { const items = e.clipboardData && e.clipboardData.items; if (!items) return; const images = []; for (const item of items) { if (item.kind === 'file' && item.type && item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) images.push(file); } } if (images.length) { e.preventDefault(); images.forEach((file) => this._addImageFile(file)); } } // ── Composer: `/` commands and `@` mentions ───────────────────────── _onComposerInput() { if (!this._panel) return; const textarea = this._panel.querySelector('textarea'); if (!textarea) return; const pos = textarea.selectionStart; const before = textarea.value.slice(0, pos); // Unicode-aware so accented skill names / paths keep the menu open. const command = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]*)$/u); if (command) { this._showCommandMenu(command[1]); return; } const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u); if (mention) { this._showMentionMenu(mention[1]); return; } this._hideMenus(); } async _ensureSkills() { if (this._skills.length) return this._skills; try { const data = await api('/api/ai/skills'); this._skills = [...(data.skills || []), ...(data.commands || [])]; } catch (e) { console.warn('AI assistant: skills load failed', e); this._skills = []; } return this._skills; } _skillById(id) { return this._skills.find((s) => s.id === id) || null; } async _showCommandMenu(query) { if (!this._panel) return; const seq = ++this._menuSeq; const skills = await this._ensureSkills(); if (seq !== this._menuSeq) return; // a newer input superseded this render const q = normalizeSearch(query); const matches = skills.filter((s) => !q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q)); this._renderMenu('command', matches, (skill) => this._selectCommand(skill)); } _selectCommand(skill) { const textarea = this._panel && this._panel.querySelector('textarea'); this._hideMenus(); if (skill.type === 'admin') { this._runAdminCommand(skill.id); if (textarea) { textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim(); } return; } if (skill.special === 'create_skill') { if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim(); this._promptCreateSkill(); return; } this._activeSkill = skill.id; if (textarea) { textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim(); textarea.focus(); } this._renderAttachments(); } async _showMentionMenu(query) { if (!this._panel) return; const seq = ++this._menuSeq; const vault = this._activeVault(); const q = normalizeSearch(query); try { let results = []; // Fast path: filter the prefetched flat path list on the client so the // menu updates instantly while typing. const cached = await this._ensurePaths(); if (seq !== this._menuSeq) return; if (cached.length) { const matches = q ? cached.filter((p) => normalizeSearch(p.path).includes(q)) : cached; results = matches.slice(0, 20); } else if (query) { const searchVault = vault || 'all'; const data = await api( `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}` ); results = (data.results || []).slice(0, 20); } else if (vault) { const data = await api( `/api/vault/${encodeURIComponent(vault)}/files?limit=20&recursive=true` ); results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file', vault })); } if (seq !== this._menuSeq) return; // superseded while the request ran const items = results.map((r) => { const path = r.path || r; const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file'); const type = rawType === 'directory' ? 'dir' : rawType; return { id: path, label: path, type, vault: r.vault || vault || null }; }); // Always render the menu (even empty) so typing `@` gives visible feedback. this._renderMenu('mention', items, (item) => this._selectMention(item)); } catch (e) { if (seq !== this._menuSeq) return; console.warn('AI assistant: mention search failed', e); this._renderMenu('mention', [], () => {}); } } _selectMention(item) { const textarea = this._panel && this._panel.querySelector('textarea'); this._hideMenus(); if (textarea) { textarea.value = textarea.value.replace(/(^|\s)@[\p{L}\p{N}._/-]*$/u, '$1').trim(); textarea.focus(); } const path = item.id; const vault = item.vault || this._activeVault(); if (IMAGE_EXT_RE.test(path)) { this._addImagePath(path, vault); } else if (item.type === 'dir' || this._classifyPath(path) === 'dir') { this._addAdhocDir(path, vault); } else { this._addAdhocFile(path, vault); } } _addImagePath(path, vault) { if (!path || this._images.some((img) => img.path === path)) return; this._images.push({ path, name: path.split('/').pop(), vault: vault || null }); this._renderAttachments(); } _renderMenu(kind, items, onPick) { if (!this._panel) return; const menu = this._panel.querySelector(`.bookslm-${kind}-menu`); if (!menu) return; menu.innerHTML = ''; this._menuKind = kind; this._menuItems = items; this._menuIndex = 0; if (!items.length) { const empty = document.createElement('div'); empty.className = 'bookslm-menu-empty'; empty.textContent = t('ai.no_results'); menu.appendChild(empty); menu.classList.remove('hidden'); return; } items.forEach((item, index) => { const el = document.createElement('button'); el.type = 'button'; el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : ''); el.dataset.index = String(index); el.setAttribute('role', 'option'); el.setAttribute('aria-selected', index === 0 ? 'true' : 'false'); const icon = document.createElement('span'); icon.className = 'bookslm-menu-icon'; icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄'); const label = document.createElement('span'); label.className = 'bookslm-menu-label'; label.textContent = item.label || item.id; el.appendChild(icon); el.appendChild(label); if (item.usage) { const usage = document.createElement('span'); usage.className = 'bookslm-menu-usage'; usage.textContent = item.usage; el.appendChild(usage); } else if (item.description) { const desc = document.createElement('span'); desc.className = 'bookslm-menu-desc'; desc.textContent = item.description; el.appendChild(desc); } el.addEventListener('mouseenter', () => this._highlightMenu(index)); el.addEventListener('click', (ev) => { ev.stopPropagation(); onPick(item); }); menu.appendChild(el); }); // Show the other menu hidden. this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => { if (m !== menu) m.classList.add('hidden'); }); menu.classList.remove('hidden'); } _highlightMenu(index) { if (!this._panel || !this._menuKind) return; const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`); if (!menu) return; const options = menu.querySelectorAll('.bookslm-menu-item'); if (!options.length) return; const next = ((index % options.length) + options.length) % options.length; this._menuIndex = next; options.forEach((el, i) => { const on = i === next; el.classList.toggle('active', on); el.setAttribute('aria-selected', on ? 'true' : 'false'); }); const active = options[next]; if (active && typeof active.scrollIntoView === 'function') { active.scrollIntoView({ block: 'nearest' }); } } _handleMenuKey(e) { if (!this._panel || !this._menuKind) return false; const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`); if (!menu || menu.classList.contains('hidden')) return false; const items = this._menuItems; if (e.key === 'Escape') { e.preventDefault(); this._hideMenus(); return true; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!items.length) return true; const delta = e.key === 'ArrowDown' ? 1 : -1; const next = (this._menuIndex + delta + items.length) % items.length; this._highlightMenu(next); return true; } if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); const item = items[this._menuIndex]; if (!item) return true; const menuEl = menu.querySelector('.bookslm-menu-item.active'); if (menuEl) menuEl.click(); return true; } return false; } _hideMenus() { if (!this._panel) return; // Invalidate any in-flight async menu render. this._menuSeq++; this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => { m.classList.add('hidden'); }); this._menuKind = null; this._menuItems = []; this._menuIndex = 0; } // ── Admin commands (`/help`, `/providers`, …) ─────────────────────── async _runAdminCommand(id) { switch (id) { case 'help': return this._cmdHelp(); case 'providers': return this._cmdProviders(); case 'keys': return this._cmdKeys(); case 'provider': return this._cmdSwitchProvider(); case 'model': return this._cmdSwitchModel(); default: return undefined; } } _pushAssistantNote(markdown) { this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null }); this._renderMessages(); this._saveHistory(); } async _cmdHelp() { const skills = await this._ensureSkills(); const lines = [`## ${t('ai.commands')}`, '']; for (const s of skills) { lines.push(`- ${s.icon || ''} \`/${s.id}\` — ${s.description || s.label}`); } this._pushAssistantNote(lines.join('\n')); } async _cmdProviders() { let providers = {}; try { const status = await api('/api/ai/status'); providers = status.providers || {}; } catch { /* */ } const lines = [`## ${t('ai.providers')}`, '']; const names = Object.keys(providers); if (!names.length) lines.push(t('ai.none')); for (const name of names) { const info = providers[name] || {}; lines.push(`- ${info.available ? '✅' : '❌'} **${name}** — ${info.model || '—'}`); } this._pushAssistantNote(lines.join('\n')); } async _cmdKeys() { const lines = [`## ${t('ai.keys')}`, '']; try { const keys = await api('/api/config/ai-keys'); for (const [env, value] of Object.entries(keys)) { lines.push(`- ${value ? '✅' : '❌'} **${env.replace('_API_KEY', '').toLowerCase()}**`); } } catch { const status = await api('/api/ai/status').catch(() => ({ providers: {} })); for (const [name, info] of Object.entries(status.providers || {})) { lines.push(`- ${info.available ? '✅' : '❌'} **${name}**`); } } this._pushAssistantNote(lines.join('\n')); } _cmdSwitchProvider() { const names = Array.from(this._panel.querySelectorAll('.ai-picker-select option')) .map((o) => o.value).filter(Boolean); const list = names.join(', '); this._pushAssistantNote( `### ${t('ai.provider')}\n\n${t('ai.usage_provider', { list })}\n\n\`/provider \``, ); } _cmdSwitchModel() { const names = Array.from(this._panel.querySelectorAll('.ai-picker-model option')) .map((o) => o.value).filter(Boolean); const list = names.length ? names.join(', ') : '—'; this._pushAssistantNote( `### ${t('ai.model')}\n\n${t('ai.usage_model', { list })}\n\n\`/model \``, ); } /** Apply a provider/model selection from an admin command text. */ _applyPickerSelection(provider, model) { if (!this._panel) return; const providerSelect = this._panel.querySelector('.ai-picker-select'); const modelSelect = this._panel.querySelector('.ai-picker-model'); let state = {}; try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ } if (provider && providerSelect) { state.provider = provider; state.model = model || null; delete state.capabilities; providerSelect.value = provider; providerSelect.dispatchEvent(new Event('change')); if (model) { const applyModel = () => { if (modelSelect && Array.from(modelSelect.options).some((o) => o.value === model)) { modelSelect.value = model; modelSelect.dispatchEvent(new Event('change')); } }; setTimeout(applyModel, 400); } return; } if (model && modelSelect) { state.model = model; delete state.capabilities; modelSelect.value = model; modelSelect.dispatchEvent(new Event('change')); } } /** Handle `/provider X` and `/model X` typed directly (with an argument). */ _maybeRunAdminWithArg(text) { const provider = text.match(/^\/provider\s+(\S+)\s*$/i); if (provider) { this._applyPickerSelection(provider[1], null); this._pushAssistantNote(`✅ ${t('ai.provider_changed', { name: provider[1] })}`); return true; } const model = text.match(/^\/model\s+(\S+)\s*$/i); if (model) { this._applyPickerSelection(null, model[1]); this._pushAssistantNote(`✅ ${t('ai.model_changed', { name: model[1] })}`); return true; } return false; } // ── Custom skill creation (`/create-new-skill`) ───────────────────── _promptCreateSkill() { const overlay = document.createElement('div'); overlay.className = 'bookslm-modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelector('.bookslm-modal-cancel').addEventListener('click', close); overlay.querySelector('.bookslm-modal-ok').addEventListener('click', async () => { const id = overlay.querySelector('.bookslm-skill-id').value.trim().toLowerCase(); const label = overlay.querySelector('.bookslm-skill-label').value.trim(); const prompt = overlay.querySelector('.bookslm-skill-prompt').value.trim(); const err = overlay.querySelector('.bookslm-modal-error'); try { await api('/api/ai/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, label, prompt }), }); this._skills = []; close(); showToast(t('ai.skill_created', { name: label || id }), 'success'); } catch (e) { err.textContent = e.message || String(e); } }); } // ── Vision support ────────────────────────────────────────────────── async _modelSupportsVision() { let state = {}; try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ } const provider = state.provider || null; const model = state.model || ''; if (!provider) return true; // provider default resolution is server-side let caps = state.capabilities; if (!caps) caps = await getModelCapabilities(provider, model); return !caps || caps.vision !== false; } _updateHeader() { if (!this._panel) return; const meta = this._contextMeta(); const titles = this._panel.querySelector('.bookslm-header-titles'); 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 (titles) titles.title = meta.tooltip || meta.subtitle || meta.title || ''; if (icon) icon.setAttribute('data-lucide', meta.icon); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } /** * Show the assistant activity state in the panel (visual workflow). * @param {'idle'|'working'|'done'|'error'} kind * @param {string} [text] */ _setActivity(kind, text) { if (!this._panel) return; if (this._activityHideTimer) { clearTimeout(this._activityHideTimer); this._activityHideTimer = null; } const el = this._panel.querySelector('.bookslm-activity'); if (!el) return; const label = el.querySelector('.bookslm-activity-text'); if (label) label.textContent = text || ''; el.classList.toggle('hidden', kind === 'idle'); el.classList.toggle('working', kind === 'working'); el.classList.toggle('done', kind === 'done'); el.classList.toggle('error', kind === 'error'); } _updateStatus(data) { if (!this._panel) return; const status = this._panel.querySelector('.bookslm-status'); if (!status) return; if (data && data.error) { status.classList.remove('hidden'); status.innerHTML = `⚠ ${t('bookslm.no_context')}`; return; } if (data && Array.isArray(data.files)) { if (data.scope === MODE.GENERAL) { // No document context: keep the header clean. The description is // available as a tooltip on the header title. status.classList.add('hidden'); status.innerHTML = ''; return; } status.classList.remove('hidden'); 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 = `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`; status.innerHTML = ` ${label}
`; } else if (this._isLoading) { status.classList.remove('hidden'); 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; // Admin slash-commands are handled locally (no LLM round-trip). if (/^\/(?:help|providers|keys|provider|model)(?:\s|$)/i.test(text)) { this._hideMenus(); textarea.value = ''; textarea.style.height = 'auto'; if (!this._maybeRunAdminWithArg(text)) { this._runAdminCommand(text.slice(1).split(/\s+/)[0].toLowerCase()); } return; } // Vision gate: block sending images to a non-vision model. if (this._images.length) { const visionOk = await this._modelSupportsVision(); if (!visionOk) { showToast(t('ai.vision_unsupported'), 'error'); 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._contextVault(), directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], extra_files: this._adhocFiles.map((f) => f.path), extra_directories: this._adhocDirs.map((d) => d.path), images: this._images.map((img) => ( img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type } )), skill: this._activeSkill, 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(); this._setActivity('working', t('ai.activity_sending')); 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); } this._setActivity('working', t('ai.activity_streaming')); 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); } this._setActivity('done', t('ai.activity_done')); this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500); } catch (e) { if (e.name !== 'AbortError') { assistantMsg.content = `⚠ Error: ${e.message}`; console.warn('AI assistant chat error:', e); this._setActivity('error', t('ai.activity_error')); } else { this._setActivity('idle'); } } 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) { // Images require the plain multimodal chat endpoint (the agent loop is // text/tool oriented). const hasImages = Array.isArray(payload.images) && payload.images.length > 0; const endpoint = (this._agentMode && !hasImages) ? '/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 || {} }); this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; } if (currentEvent === 'confirmation') { assistantMsg.confirmation = { pending: data.pending || data, messages: data.messages || [], }; this._setActivity('working', t('ai.activity_confirmation')); return; } if (currentEvent === 'error' || data.error) { const msg = data.error || t('bookslm.error'); assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg; this._setActivity('error', t('ai.activity_error')); return; } const chunk = data.token || data.content || ''; if (chunk) { if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming')); 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 };