// 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. // Unicode-aware (`\p{L}\p{N}\p{M}`) so accented names — either precomposed // (é) or decomposed (e + combining acute) — are recognised. const PATH_CHARS = '[\\p{L}\\p{N}\\p{M}._-]'; const PATH_WITH_DIR_RE = new RegExp( `(^|[\\s(>"'])((?:${PATH_CHARS}+\\/)+${PATH_CHARS}+\\.[A-Za-z0-9]{1,8})`, 'gu', ); // Same, but each segment may contain spaces (`Mon dossier/Ma note.md`). Matched // only when the candidate resolves against the vault path index so ordinary // prose is never turned into a link. const PATH_WITH_SPACES_RE = /(^|[\s(>"'\[])((?:[^\s/]+(?:[ \t]+[^\s/]+)*\/)+[^\s/]+(?:[ \t]+[^\s/]+)*\.[A-Za-z0-9]{1,8})/g; // Characters allowed in a single file/directory name (accented letters // included). Used to tell a path from prose or a code snippet. const PATH_NAME_RE = /^[\p{L}\p{N}\p{M}._/\\ ()'&+-]+$/u; // 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; // Deep Research: a skill-like chip that enables agent tools and injects a // research directive into the request (without polluting the composer). this._activeDeepResearch = false; 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; // True while the latest question should stay pinned to the top of the // thread (cleared as soon as the user scrolls manually). this._pinnedTurn = false; // Cache of every indexed path per vault, backing instant `@` filtering. this._pathCache = null; this._pathCacheVault = null; this._pathCachePromise = null; // #95 — Debounced server sync of the session history (persistent backend). this._syncTimer = null; this._syncInFlight = false; this._dirtySessionIds = new Set(); this._deletedIds = new Set(); // #97 — Extensible "+" panel entries. A plain array so future plugins can // push their own options at runtime (modular by construction). this._extensions = 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] * @param {string|null} [preferredSessionId] #95/#96 — when set, this session * is selected (and hydrated) instead of the latest of the context. */ async openContext(opts = {}, preferredSessionId = null) { 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 = preferredSessionId == null && 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._activeDeepResearch = false; this._isLoading = true; await this._loadHistory(preferredSessionId); 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({ anchor: true }); 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 }); } /** * #95/#96 — Open a specific conversation from the persistent history. * @param {string|object} sessionOrId — full session object or session id. */ async openWithSession(sessionOrId) { const id = typeof sessionOrId === 'string' ? sessionOrId : (sessionOrId && sessionOrId.id); if (!id) return; let session = typeof sessionOrId === 'object' ? sessionOrId : null; if (!session || !Array.isArray(session.messages)) { const fetched = await this._fetchSessionFromServer(id).catch(() => null); if (fetched) session = this._normalizeSession(fetched); } if (!session) session = this._sessions.find((s) => s.id === id) || null; if (!session) return; this._positionSession(session); await this.openContext({ mode: session.mode || MODE.GENERAL, vault: session.vault || null, directory: session.directory || '', documents: Array.isArray(session.documents) ? session.documents : [], }, id); } /** * 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._dirtySessionIds.add(session.id); 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); } _normalizeLinkPath(rawPath) { return String(rawPath || '') .normalize('NFC') .trim() .replace(/^\.\//, '') .replace(/\\/g, '/') .replace(/^\/+/, '') .replace(/\/+$/, '') .trim(); } /** * Comparison key for a vault path: NFC-normalised and lowercased, so a model * writing a decomposed accent (`e` + combining acute, as macOS stores it) * still matches a precomposed index entry, and vice versa. */ _normKey(value) { return String(value || '').normalize('NFC').toLowerCase(); } /** Vault names the client knows about (sidebar list, current context). */ _knownVaultNames() { const names = new Set(); const add = (entry) => { const name = typeof entry === 'string' ? entry : (entry && entry.name); if (name && name !== 'all') names.add(String(name)); }; try { if (Array.isArray(state.allVaults)) state.allVaults.forEach(add); } catch { /* not ready */ } add(this._vault); try { add(state.currentVault); } catch { /* not ready */ } return names; } /** * Split an optional leading vault name off a mentioned path, e.g. * `TestVault/Recettes/Pizza Maison.md` → `{vault: 'TestVault', path: 'Recettes/Pizza Maison.md'}`. * The prefix is only recognised when it matches a known vault name and is * followed by at least one more segment. */ _splitVaultPrefix(rawPath) { const clean = this._normalizeLinkPath(rawPath); if (!clean) return { vault: null, path: clean }; const idx = clean.indexOf('/'); if (idx <= 0) return { vault: null, path: clean }; const head = clean.slice(0, idx); const rest = clean.slice(idx + 1); if (!rest) return { vault: null, path: clean }; for (const name of this._knownVaultNames()) { if (this._normKey(name) === this._normKey(head)) return { vault: name, path: rest }; } return { vault: null, path: clean }; } /** * Synchronous lookup of a (possibly space-containing or vault-prefixed) path * in the cached vault path index. Used at render time to decide whether a bare * mention is a real vault path before turning it into a link. */ _confirmPathInCache(rawPath) { const paths = this._pathCache; if (!Array.isArray(paths) || !paths.length) return null; const { vault, path } = this._splitVaultPrefix(rawPath); const active = this._activeVault(); if (vault && active && this._normKey(vault) !== this._normKey(active)) { // Another vault cannot be validated synchronously: accept the relative // path and let the click-time resolution fetch that vault's index. return { path, vault }; } const clean = this._normKey(path); if (!clean) return null; const hit = paths.find((p) => this._normKey(p.path) === clean) || paths.find((p) => this._normKey(p.path).endsWith('/' + clean)); return hit ? { ...hit, vault: vault || active || null } : null; } /** * Linkify bare path mentions that contain spaces (`Mon dossier/Ma note.md`). * * A candidate is accepted only when the longest word-aligned suffix exists in * the vault path index. That keeps ordinary prose unturned-into-links and * prevents leading sentence words ("Ouvre Mon dossier/…") from being swallowed * into the link. Returns the segment unchanged when no path index is available. */ _linkifySpacePaths(segment, put) { const paths = this._pathCache; if (!Array.isArray(paths) || !paths.length) return segment; // Fresh regex instance: PATH_WITH_SPACES_RE is module-level and its // lastIndex must not leak between (possibly nested) render calls. const re = new RegExp(PATH_WITH_SPACES_RE.source, 'g'); let out = ''; let last = 0; let m; while ((m = re.exec(segment)) !== null) { const pre = m[1] || ''; const cand = m[2]; const candStart = m.index + pre.length; if (candStart < last) { re.lastIndex = candStart + 1; continue; } const accepted = this._acceptSpaceCandidate(this._decodeEntities(cand)); if (!accepted) { // Rejected: retry from the next character so an over-long candidate // (leading prose word) does not mask the real path inside it. re.lastIndex = candStart + 1; continue; } out += segment.slice(last, candStart + accepted.offset); // The accepted text (vault prefix and accents included) is both the link // label and the path to resolve at click time. out += put(this._pathLink(accepted.display, accepted.display)); last = candStart + cand.length; re.lastIndex = last; } return out ? out + segment.slice(last) : segment; } /** Longest word-aligned suffix of `cand` that exists in the path index. */ _acceptSpaceCandidate(cand) { const wordRe = /\S+/g; let w; while ((w = wordRe.exec(cand)) !== null) { const sub = cand.slice(w.index); if (!sub.includes('/')) continue; if (this._confirmPathInCache(sub)) return { display: sub, offset: w.index }; } return null; } /** * Resolve a mentioned path against a vault's indexed paths so links keep * working even when the model writes a slightly different path — leading * slash or backslashes, a bare basename, a leading vault name * (`TestVault/Recettes/Pizza.md`) or a differently-normalised accent. * * @param {string} rawPath Mentioned path. * @param {string|null} [vaultOverride] Vault to look into when the mention * carries no `Vault/…` prefix. * @returns {Promise<{path: string, name?: string, type?: string, vault: string|null}|null>} */ async _resolveExistingPath(rawPath, vaultOverride) { const split = this._splitVaultPrefix(rawPath); const vault = split.vault || vaultOverride || this._activeVault(); const clean = this._normKey(split.path); if (!clean) return null; const paths = await this._fetchPathsForVault(vault); if (!Array.isArray(paths) || !paths.length) return null; const found = this._matchIndexedPath(paths, clean); if (found) return { ...found, vault }; // The leading segment may be a vault name the client does not know about // (or an extra directory the index does not carry): retry one segment down. const slash = split.path.indexOf('/'); if (slash > 0) { const reduced = this._normKey(split.path.slice(slash + 1)); if (reduced) { const fallback = this._matchIndexedPath(paths, reduced); if (fallback) return { ...fallback, vault }; } } return null; } /** Match a normalised path against the index (exact → suffix → basename). */ _matchIndexedPath(paths, clean) { const exact = paths.find((p) => this._normKey(p.path) === clean); if (exact) return exact; // Suffix match: the model may omit a leading directory segment. const suffix = paths.find((p) => this._normKey(p.path).endsWith('/' + clean)); if (suffix) return suffix; // Unique basename match (prefer files over directories). const base = clean.split('/').pop(); const matches = paths.filter((p) => this._normKey(p.name || String(p.path || '').split('/').pop()) === base); if (matches.length === 1) return matches[0]; if (matches.length > 1) { const file = matches.find((p) => p.type === 'file'); if (file) return file; } return null; } /** * Path index for a specific vault. Uses the cached list when it is the one * already loaded (the `@` menu / render-time checks) and otherwise performs a * one-off fetch without clobbering that cache. */ async _fetchPathsForVault(vault) { if (!vault) return []; if (Array.isArray(this._pathCache) && this._pathCacheVault === vault) return this._pathCache; try { const data = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`); return Array.isArray(data.results) ? data.results : []; } catch { return []; } } /** Copy a mentioned filename to the clipboard (bare-name links). */ _copyLinkName(path) { const name = this._normalizeLinkPath(path).split('/').pop(); if (!name) return; const done = () => showToast(t('bookslm.copied_name', { name }), 'success'); try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(name).then(done).catch(() => this._fallbackCopy(name, done)); return; } } catch { /* fall through */ } this._fallbackCopy(name, done); } _fallbackCopy(text, done) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); if (typeof done === 'function') done(); } catch { /* clipboard unavailable */ } } /** * Route a clicked path link: open the file in the viewer, reveal a directory * in the tree, or (when the path cannot be resolved) copy the filename so the * user still gets something useful instead of "File not found". */ async _activatePath(rawPath, kind) { const split = this._splitVaultPrefix(rawPath); const vault = split.vault || this._activeVault(); if (!vault) { showToast(t('bookslm.no_vault'), 'error'); return; } const resolved = await this._resolveExistingPath(rawPath); if (resolved) { const targetVault = resolved.vault || vault; if (resolved.type === 'directory') return this._revealPath(resolved.path, targetVault); return this._openFileLink(resolved.path, targetVault); } if (kind === 'dir') { // Unknown directory: still try to reveal it in the tree (best effort). return this._revealPath(split.path, vault); } // Unknown file: copy the name rather than opening a dead link. this._copyLinkName(rawPath); } /** 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, vaultOverride) { const vault = vaultOverride || 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, vaultOverride) { const vault = vaultOverride || 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; } /** * Collect the live application state sent to the General assistant so it * knows what the user is looking at: open documents, current viewer file, * current directory/vault, active search and the files shown on the search * results page. Best-effort — any failure yields a partial object. */ _buildAppContext() { const ctx = {}; try { if (this._vault) ctx.vault = this._vault; else if (state.selectedContextVault && state.selectedContextVault !== 'all') { ctx.vault = state.selectedContextVault; } if (this._directory) ctx.directory = this._directory; if (state.currentVault) ctx.current_vault = state.currentVault; if (state.currentPath) ctx.current_path = state.currentPath; const docs = collectOpenDocuments(); if (docs.length) { ctx.open_documents = docs.map((d) => ({ vault: d.vault, path: d.path })); } // #93 — Live edition session: the document currently replaced by the // editor/Forge surface. Knowing it lets the assistant target "le document // que je suis en train d'éditer" and update it in place. const editing = this._editingDocument(); if (editing) ctx.editing = editing; const query = state.advancedSearchLastQuery; if (query) { ctx.search_query = query; if (typeof state.advancedSearchTotal === 'number') { ctx.search_total = state.advancedSearchTotal; } } // Files currently rendered on the search results page, if any. const items = document.querySelectorAll('.search-result-item[data-path]'); if (items.length) { ctx.search_results = Array.from(items).slice(0, 20).map((el) => ({ vault: el.getAttribute('data-vault'), path: el.getAttribute('data-path'), })); } } catch (e) { console.warn('AI assistant: app context collection failed', e); } return ctx; } /** * #93 — The document currently being edited, i.e. the one the editor/Forge * surface replaced the read view with, or null. Sent as `app_context.editing` * so the assistant can target "le document que je suis en train d'éditer". */ _editingDocument() { try { const modal = document.getElementById('editor-modal'); if (!modal || !modal.classList.contains('active')) return null; const vault = state.editorVault; const path = state.editorPath; if (!vault || !path) return null; return { vault, path, surface: document.getElementById('forge-iframe') ? 'forge' : 'editor' }; } catch (e) { return null; } } /** * #93 — A write tool just modified a vault file. Notify the UI so the * displayed document is reloaded from disk: otherwise the read view keeps the * stale content, and an open editor buffer autosaves the old text back over * the assistant's change (see utils.reloadExternalWrite). */ _notifyFileWritten(data) { if (!data || data.ok === false) return; const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup']; if (WRITE_TOOLS.indexOf(data.name) === -1) return; const args = data.arguments || {}; if (!args.vault || !args.path) return; window.dispatchEvent( new CustomEvent('obsigate:file-written', { detail: { vault: args.vault, path: args.path, tool: data.name }, }), ); } // ── 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; // Mount inside the host (kept as an `ai-picker-slot`) so a provider // change in the config can rebuild the picker in place. pickerHost.replaceChildren(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, reveals a directory in the sidebar tree, or (for a // bare filename with no directory) copies the name to the clipboard. 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; const kind = link.getAttribute('data-kind'); if (kind === 'name') { this._copyLinkName(path); return; } this._activatePath(path, kind); }); // A manual scroll (wheel, touch or scrollbar drag) releases the // top-pinning of the latest question. const messagesEl = panel.querySelector('.bookslm-messages'); if (messagesEl) { const unpin = () => { if (!this._pinnedTurn) return; this._pinnedTurn = false; // The anchor padding is only there to make the post reachable while // it is pinned — drop it so the reader gets no blank tail. messagesEl.style.paddingBottom = ''; }; messagesEl.addEventListener('wheel', unpin, { passive: true }); messagesEl.addEventListener('touchmove', unpin, { passive: true }); messagesEl.addEventListener('mousedown', unpin, { passive: true }); } // 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)); // #97 — The "+" button opens the extensible options panel; the panel can // attach images (existing picker), upload any file, or trigger commands. const plusBtn = panel.querySelector('.bookslm-btn-plus'); const fileInput = panel.querySelector('.bookslm-file-input'); const filesInput = panel.querySelector('.bookslm-files-input'); if (plusBtn) plusBtn.addEventListener('click', () => this._toggleExtMenu()); if (fileInput) { fileInput.addEventListener('change', () => { this._addImageFiles(fileInput.files); fileInput.value = ''; }); } if (filesInput) { filesInput.addEventListener('change', () => { this._addUploadedFiles(filesInput.files); filesInput.value = ''; }); } const extMenu = panel.querySelector('.bookslm-ext-menu'); if (extMenu) { panel.addEventListener('click', (e) => { if (e.target.closest('.bookslm-ext-menu') || e.target.closest('.bookslm-btn-plus')) return; this._closeExtMenu(); }); } 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(); }); // Setup Drag & Drop for internal items and external files (#89) this._setupDropZones(panel); // Save singleton reference on window window.BooksLMInstance = this; 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)); } _setupDropZones(panel) { const composer = panel.querySelector('.bookslm-composer') || panel; const onDragOver = (e) => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; composer.classList.add('bookslm-drop-over'); }; const onDragLeave = (e) => { if (composer.contains(e.relatedTarget)) return; composer.classList.remove('bookslm-drop-over'); }; const onDrop = async (e) => { e.preventDefault(); e.stopPropagation(); composer.classList.remove('bookslm-drop-over'); // Check if internal tree drag const treeData = e.dataTransfer.getData('tree:path') || e.dataTransfer.getData('text/plain'); const treeVault = e.dataTransfer.getData('tree:vault'); const treeType = e.dataTransfer.getData('tree:type'); if (treeData && treeVault) { if (treeType === 'directory') { this._addAdhocDir(treeData, treeVault); } else { this._addAdhocFile(treeData, treeVault); } return; } // External files const files = Array.from(e.dataTransfer.files || []); if (files.length > 0) { this.handleExternalDroppedItems(files.map((f) => ({ path: f.name, file: f, isDir: false }))); } }; composer.addEventListener('dragover', onDragOver); composer.addEventListener('dragleave', onDragLeave); composer.addEventListener('drop', onDrop); } /** * Handle files/folders dropped from outside into BooksLM (#89). */ async handleExternalDroppedItems(items) { if (!this._isOpen) { this.open(); } for (const item of items) { if (item.isDir) { continue; } const file = item.file; if (!file) continue; if (file.type && file.type.startsWith('image/')) { this._addImageFile(file); } else { // Read text/markdown file content try { const text = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = reject; reader.readAsText(file); }); // Insert into context files or append to composer this._adhocFiles.push({ path: item.path, vault: null, inlineContent: text, }); this._renderAttachments(); } catch (err) { console.warn('Could not read dropped file as text:', err); } } } } _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 || this._activeDeepResearch; 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(); }, )); } if (this._activeDeepResearch) { host.appendChild(this._chip( `🔎 ${t('bookslm.ext_deep_research')}`, 'deep-research', () => { this._activeDeepResearch = false; 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)); } /** #97 — Leverage the drop-zone logic for files picked in the "+" panel. */ async _addUploadedFiles(files) { if (!files || !files.length) return; if (!this._isOpen) await this.openForCurrentContext(); const items = Array.from(files).map((file) => ({ path: file.name, file, isDir: false })); await this.handleExternalDroppedItems(items); this._closeExtMenu(); } // ── Extensible "+" panel (#97) ────────────────────────────────────── // // The panel is driven by a plain array of entries. Each entry is // {id, icon, label, hint, disabled, action} — future plugins only need to // push their own entry to appear here. _extensionItems() { if (this._extensions) return this._extensions; this._extensions = [ { id: 'files', icon: 'file-up', label: t('bookslm.ext_files'), hint: t('bookslm.ext_files_hint'), action: () => { const input = this._panel && this._panel.querySelector('.bookslm-files-input'); if (input) input.click(); }, }, { id: 'image', icon: 'image-plus', label: t('ai.attach_image'), hint: t('bookslm.ext_image_hint'), action: () => { const input = this._panel && this._panel.querySelector('.bookslm-file-input'); if (input) input.click(); }, }, { id: 'context', icon: 'folder-plus', label: t('bookslm.ext_contexts'), hint: t('bookslm.ext_contexts_hint'), action: () => { this._closeExtMenu(); this._showMentionMenu(''); }, }, { id: 'skill', icon: 'wand-2', label: t('bookslm.ext_skills'), hint: t('bookslm.ext_skills_hint'), action: () => { this._closeExtMenu(); this._showCommandMenu(''); }, }, { id: 'deep_research', icon: 'search-check', label: t('bookslm.ext_deep_research'), hint: t('bookslm.ext_deep_research_hint'), action: () => this._startDeepResearch(), }, { id: 'web', icon: 'globe', label: t('bookslm.ext_web'), hint: t('bookslm.ext_web_hint'), disabled: true, action: () => showToast(t('bookslm.coming_soon'), 'info'), }, { id: 'canva', icon: 'palette', label: t('bookslm.ext_canva'), hint: t('bookslm.ext_canva_hint'), disabled: true, action: () => showToast(t('bookslm.coming_soon'), 'info'), }, ]; return this._extensions; } _toggleExtMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-ext-menu'); if (!menu) return; const nowHidden = menu.classList.toggle('hidden'); if (!nowHidden) this._renderExtMenu(); } _closeExtMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-ext-menu'); if (menu) menu.classList.add('hidden'); } _renderExtMenu() { if (!this._panel) return; const menu = this._panel.querySelector('.bookslm-ext-menu'); if (!menu) return; menu.innerHTML = ''; for (const entry of this._extensionItems()) { const item = document.createElement('button'); item.type = 'button'; item.className = 'bookslm-ext-item'; if (entry.disabled) item.classList.add('disabled'); item.dataset.extId = entry.id; item.innerHTML = ` ${entry.label} ${entry.disabled ? '' + t('bookslm.soon') + '' : ''} ${entry.hint} `; item.addEventListener('click', (e) => { // BUG-048 — Keep the event from reaching the panel's click handler, // which would hide a mention/command menu opened by this action right // after it opens (the menu is rendered asynchronously). e.stopPropagation(); if (!entry.disabled && entry.action) entry.action(); }); menu.appendChild(item); } const footer = document.createElement('div'); footer.className = 'bookslm-ext-footer'; footer.textContent = t('bookslm.ext_more_coming'); menu.appendChild(footer); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } /** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */ _startDeepResearch() { this._closeExtMenu(); if (!this._agentMode) this._toggleAgentMode(); this._activeDeepResearch = true; this._renderAttachments(); showToast(t('bookslm.deep_research_started'), 'info'); } _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({ anchor: true }); 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(); } /** * Record the assistant activity state. * * The dedicated loading bar above the composer was removed: in agent mode * the animated indicator now lives in the steps header, and in plain chat * the pending answer shows an inline indicator. Kept as the single entry * point for the streaming code paths (state is informational only). * @param {'idle'|'working'|'done'|'error'} kind * @param {string} [text] */ _setActivity(kind, text) { this._activityKind = kind; this._activityText = text || ''; } _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(opts = {}) { if (!this._panel) return; const container = this._panel.querySelector('.bookslm-messages'); if (!container) return; const prevTop = container.scrollTop; container.innerHTML = ''; // NOTE: the anchor's bottom padding must survive the re-render. Clearing // it here (outside the synchronous scroll correction) let the browser // paint one frame with the question pushed back down the window. // Notion-style thread: chronological order, the user post anchored at the // top of the visible area (never scroll-to-bottom — the answer streams // below the question). Streaming re-renders preserve the scroll position // so the reader is never jumped around. for (const msg of this._messages) { container.appendChild(this._chatBubble(msg)); } // While the pin is active (question submitted, released by a manual // scroll) every re-render — streaming frames *and* the final one — must // put the question back at the top, otherwise the answer's own growth // would push it back down the window. if (opts.anchor || this._pinnedTurn) { this._anchorLatestUser({ instant: opts.anchor ? !!opts.instant : true }); } else { // Unpinned: drop the anchor padding so no blank gap lingers. container.style.paddingBottom = ''; container.scrollTop = prevTop; } } /** * Bring the newest user post flush to the top of the thread and keep it * there while the answer streams in. * * Two things are needed for the post to *really* sit at the top: * 1. no `scroll-margin-top` (a gap would leave the tail of the previous * answer visible above the post); * 2. a short answer cannot fill the viewport on its own, so the thread's * bottom padding is grown by exactly the missing amount — otherwise the * browser clamps the scroll and the post stops half-way down the window. */ _anchorLatestUser(opts = {}) { if (!this._panel) return; this._pinnedTurn = true; const container = this._panel.querySelector('.bookslm-messages'); if (!container) return; const users = container.querySelectorAll('.bookslm-msg.user'); const all = container.querySelectorAll('.bookslm-msg'); // Fallback (admin notes with no user post): anchor the newest message. const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild; if (!el) return; const behavior = opts.instant ? 'auto' : 'smooth'; const apply = () => { // Grow the bottom padding so the anchor position becomes reachable. const base = parseFloat(getComputedStyle(container).paddingBottom) || 0; container.style.paddingBottom = ''; const cRect = container.getBoundingClientRect(); const target = el.getBoundingClientRect().top - cRect.top + container.scrollTop; const max = container.scrollHeight - container.clientHeight; // Only when the thread really scrolls: a thread shorter than the window // already shows the post at the top, extra padding would just leave a // blank tail. if (max > 0 && target > max) { container.style.paddingBottom = `${Math.ceil(base + (target - max))}px`; } else { container.style.paddingBottom = ''; } try { el.scrollIntoView({ behavior, block: 'start' }); } catch { container.scrollTop = target; } // Correction pass: `scrollIntoView` can land a few pixels off (scroll // anchoring, sub-pixel padding, momentum scroll). Trust the measured // geometry, not the request. if (behavior === 'auto') { const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top; if (Math.abs(delta) > 1) container.scrollTop += delta; } }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply); else apply(); } /** * Build a single chat entry (user or assistant), Notion-style: * • user → right-aligned rounded grey bubble (max 80%), * • assistant → full-width text with no background bubble, a discreet * provider/model tag above, a collapsible “N steps” block for agent * tool calls, and a hover-revealed action bar (Copier) below. */ _chatBubble(msg) { const wrap = document.createElement('div'); wrap.className = 'bookslm-msg ' + msg.role; if (msg.role === 'assistant') { // Discreet provider/model tag above the answer. Uses the actual engine // reported by the backend SSE stream, so it reflects the real model even // on the provider-default selection. if (msg.provider || msg.model) { const metaLine = document.createElement('div'); metaLine.className = 'bookslm-msg-meta'; const label = [msg.provider, msg.model].filter(Boolean).join(' · '); metaLine.textContent = label; metaLine.title = label; wrap.appendChild(metaLine); } // Notion-style: the tool calls collapse into a “N étapes” line above the // answer instead of a visible list. const running = this._isRunningMessage(msg); if (msg.toolCalls && msg.toolCalls.length) { wrap.appendChild(this._renderToolActivity(msg.toolCalls, msg, running)); } const bubble = document.createElement('div'); bubble.className = 'bookslm-bubble assistant'; const { text, actions } = this._extractActions(msg.content || ''); bubble.innerHTML = this._renderMarkdown(text); // Plain chat has no steps block: the pending answer itself carries the // animated indicator until the first token arrives. if (running && !text && !actions.length) { const pending = document.createElement('div'); pending.className = 'bookslm-pending'; pending.appendChild(this._dots()); bubble.appendChild(pending); } 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); } wrap.appendChild(bubble); } else { const bubble = document.createElement('div'); bubble.className = 'bookslm-bubble user'; bubble.textContent = msg.content; wrap.appendChild(bubble); } // Notion-style action bar revealed on hover of the message block. this._appendActionBar(wrap, msg); return wrap; } /** Hover action bar under a message: Copy (both roles) + Add (assistant). */ _appendActionBar(wrap, msg) { const content = String(msg.content || '').trim(); if (!content) return; const bar = document.createElement('div'); bar.className = 'bookslm-msg-actions'; bar.appendChild(this._actionBtn(t('bookslm.copy'), 'copy', t('bookslm.copy'), () => { this._copyText(content); })); if (msg.role === 'assistant') { bar.appendChild(this._actionBtn(t('bookslm.insert'), 'insert', t('bookslm.insert_hint'), () => { this._insertIntoEditor(content); })); } wrap.appendChild(bar); } _actionBtn(label, name, title, onClick) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `bookslm-msg-action bookslm-msg-action-${name}`; btn.title = title; btn.setAttribute('aria-label', title); const icon = document.createElement('span'); icon.setAttribute('data-lucide', name === 'copy' ? 'copy' : 'corner-down-left'); icon.style.cssText = 'width:12px;height:12px'; btn.appendChild(icon); const text = document.createElement('span'); text.textContent = label; btn.appendChild(text); btn.addEventListener('click', onClick); if (typeof safeCreateIcons === 'function') safeCreateIcons(); return btn; } /** * Append an assistant answer to the Forge editor document (Notion “Ajouter”). * No-ops with a toast when no editor session is open. */ _insertIntoEditor(text) { const view = state.editorView; if (!view || !view.state || typeof view.dispatch !== 'function') { showToast(t('bookslm.insert_no_editor'), 'info'); return; } try { const at = view.state.selection.main.to; const insert = (at > 0 ? '\n' : '') + text; view.dispatch({ changes: { from: at, insert }, selection: { anchor: at + insert.length }, }); if (typeof view.focus === 'function') view.focus(); showToast(t('bookslm.inserted'), 'success'); } catch (e) { console.warn('AI assistant: insert into editor failed', e); showToast(t('bookslm.insert_no_editor'), 'error'); } } _copyText(text) { const done = () => showToast(t('bookslm.copied'), 'success'); try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done).catch(() => this._fallbackCopy(text, done)); return; } } catch { /* fall through */ } this._fallbackCopy(text, done); } // ── 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) ────────────────────── /** Human one-liner for a recorded step (backend label key or legacy name). */ _stepText(call) { const step = call.step; if (step && step.key) { const key = `ai.step.${step.key}`; const label = t(key, step.params || {}); if (label && label !== key) return label; } return t('ai.tool_call', { name: call.name }); } /** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */ _chevron() { const c = document.createElement('span'); c.className = 'bookslm-chevron'; c.setAttribute('aria-hidden', 'true'); return c; } /** True while this very message is the one being produced. */ _isRunningMessage(msg) { return !!this._isLoading && this._messages[this._messages.length - 1] === msg; } /** * Agent tool calls collapse into a discreet “N étapes” block (Notion-style). * * The header carries an animated indicator while the agent works; expanding * it reveals the chronological steps, where each reasoning note becomes a * « Réflexion » sub-section and web searches add a clickable sources list. * Open states live on the message so the re-render of every streamed token * never collapses what the user expanded. */ _renderToolActivity(toolCalls, msg, running) { const wrap = document.createElement('details'); wrap.className = 'bookslm-tool-trace'; wrap.open = !!(msg && msg._stepsOpen); const summary = document.createElement('summary'); if (running) { const dots = this._dots(); dots.classList.add('bookslm-steps-dots'); summary.appendChild(dots); } const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; const label = document.createElement('span'); label.className = 'bookslm-steps-label'; label.textContent = t(countKey, { count: toolCalls.length }); summary.appendChild(label); summary.appendChild(this._chevron()); if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`); wrap.appendChild(summary); const body = document.createElement('div'); body.className = 'bookslm-steps-body'; const sources = []; for (const call of toolCalls) { // A reasoning note is its own collapsible sub-section, not a line. if (call.step && call.step.key === 'thought') { body.appendChild(this._renderThought(call, msg)); continue; } const line = document.createElement('div'); line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); line.textContent = `${call.ok === false ? '⚠' : '·'} ${this._stepText(call)}`; if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name }); else if (call.step && call.step.params && call.step.params.value) line.title = call.step.params.value; body.appendChild(line); if (Array.isArray(call.sources)) { for (const s of call.sources) { if (s && s.url) sources.push(s); } } } if (sources.length) body.appendChild(this._renderSources(sources, msg)); wrap.appendChild(body); wrap.addEventListener('toggle', () => { if (msg) msg._stepsOpen = wrap.open; }); return wrap; } /** Animated three-dot activity indicator (pure CSS, reduced-motion aware). */ _dots() { const dots = document.createElement('span'); dots.className = 'bookslm-dots'; dots.setAttribute('aria-hidden', 'true'); dots.innerHTML = ''; return dots; } /** One collapsible « Réflexion » block holding the model's reasoning note. */ _renderThought(call, msg) { const text = (call.step && call.step.params && call.step.params.value) || ''; const box = document.createElement('details'); box.className = 'bookslm-thought'; box.open = !!(msg && msg._thoughtOpen && msg._thoughtOpen[text]); const summary = document.createElement('summary'); const label = document.createElement('span'); label.className = 'bookslm-thought-label'; label.textContent = t('ai.thought_label'); summary.appendChild(label); summary.appendChild(this._chevron()); box.appendChild(summary); const body = document.createElement('div'); body.className = 'bookslm-thought-text'; body.textContent = text; box.appendChild(body); box.addEventListener('toggle', () => { if (!msg) return; msg._thoughtOpen = msg._thoughtOpen || {}; msg._thoughtOpen[text] = box.open; }); return box; } /** Web sources gathered from the run's searches, as real links. */ _renderSources(list, msg) { const box = document.createElement('details'); box.className = 'bookslm-web-sources'; // Visible by default: the point of the section is to expose the links. box.open = !(msg && msg._sourcesOpen === false); const summary = document.createElement('summary'); const label = document.createElement('span'); label.className = 'bookslm-web-sources-label'; label.textContent = t('ai.sources_label', { count: list.length }); summary.appendChild(label); summary.appendChild(this._chevron()); box.appendChild(summary); const ul = document.createElement('ul'); ul.className = 'bookslm-web-sources-list'; for (const s of list) { const li = document.createElement('li'); const a = document.createElement('a'); a.href = s.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = s.title || s.url; a.title = s.url; li.appendChild(a); ul.appendChild(li); } box.appendChild(ul); box.addEventListener('toggle', () => { if (msg) msg._sourcesOpen = box.open; }); return box; } _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; } /** * Build a readable Error from a non-OK /chat or /agent response. BUG-046: * a 422 validation error carries `detail` as an ARRAY of objects (FastAPI), * which `new Error(detail)` stringified to "[object Object]" in the * "Échec de l'action" toast. Flatten arrays / objects into their messages. */ async _responseError(resp) { let detail = `HTTP ${resp.status}`; try { const body = await resp.json(); const d = body && body.detail; if (typeof d === 'string' && d) detail = d; else if (Array.isArray(d) && d.length) { detail = d.map((item) => (item && (item.msg || item.message)) || JSON.stringify(item)).join(' · '); } else if (d && typeof d === 'object') { detail = d.msg || d.message || JSON.stringify(d); } } catch { /* no json body */ } return new Error(detail); } 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; // BUG-046: carry the original request payload over to the continuation. // When an applied tool failed and the model re-proposed a confirmation, // the continuation used to have `payload: null`: the second "Appliquer" // then resumed without `message` → 422 → "[object Object]" toast. const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.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) { throw await this._responseError(resp); } // The confirmation is resolved: drop the card and show the continuation. msg.confirmation = null; this._messages.push(continuation); this._renderMessages({ anchor: true }); 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 }); // Instant anchor: the post must already sit at the top when the answer // starts streaming (a smooth animation would leave it at the bottom). this._renderMessages({ anchor: true, instant: true }); 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: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text, conversation_history: history, provider, model, app_context: this._buildAppContext(), }; const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload }; this._messages.push(assistantMsg); // Loading must be flagged *before* this render: otherwise it would take // the "restore previous scroll" branch and cancel the anchor applied to // the user post, leaving the new question at the bottom until the first // token arrives. this._isLoading = true; this._renderMessages({ anchor: true, instant: 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')); } 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 || {}, step: data.step || null, sources: Array.isArray(data.sources) ? data.sources : [], }); // #93 — a vault write must be reflected in the displayed document. this._notifyFileWritten(data); this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; } // Intermediate reasoning note streamed by the agent (Notion-style // ". thought" line inside the steps block). if (currentEvent === 'step' && data.step) { assistantMsg.toolCalls.push({ name: '', ok: true, arguments: {}, step: data.step }); this._setActivity('working', t('ai.activity_thinking')); 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 || ''; // Remember which provider/model actually answered, so a discreet tag // can be shown above the assistant's text block. if (data.provider) assistantMsg.provider = data.provider; if (data.model) assistantMsg.model = data.model; 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). The target may contain spaces // (`Mon dossier/Ma note.md`), an optional wrapper and an // optional "title" — all stripped before resolving. s = s.replace(/\[([^\]\n]+)\]\(([^)\n]*)\)/g, (_m, label, target) => { let linkTarget = String(target).trim(); if (linkTarget.startsWith('<') && linkTarget.endsWith('>')) linkTarget = linkTarget.slice(1, -1).trim(); linkTarget = linkTarget.replace(/\s+["'][^"']*["']$/, '').trim(); if (!linkTarget) return _m; return put(this._renderMdLink(label, linkTarget)); }); // Bare mentions whose file/directory names contain spaces. Run BEFORE the // space-free pass so the full path is captured, and link only when the // candidate exists in the vault index — ordinary prose is never turned into // a dead link. s = this._linkifySpacePaths(s, put); // Bare `dir/file.ext` mentions (no spaces). 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}`; } // Links emitted by models are sometimes percent-encoded (spaces as %20). let decoded = this._decodeEntities(target); if (/%[0-9a-f]{2}/i.test(decoded)) { try { decoded = decodeURIComponent(decoded); } catch { /* keep as-is */ } } return this._pathLink(label, decoded); } /** Build a clickable link for a vault-relative file or directory path. */ _pathLink(display, rawPath) { const clean = this._normalizeLinkPath(this._decodeEntities(rawPath)); const kind = this._classifyPath(clean); const attr = this._escapeAttr(clean); return `${display}`; } _classifyPath(path) { const p = String(path || ''); // A bare filename (no directory) is copied to the clipboard instead of // being treated as a vault path — avoids "File not found" dead links. if (!p.includes('/')) return FILE_EXT_RE.test(p) ? 'name' : 'dir'; return FILE_EXT_RE.test(p) ? 'file' : 'dir'; } _looksLikePath(value) { if (value == null) return false; const p = String(value).trim(); if (!p || /[\n\r]/.test(p)) return false; // Allow spaces and accented characters inside file/directory names // (`Ma note.md`, `Recettes/Préparation.md`), but keep the character set // tight so prose and code snippets are not mistaken for paths. if (!PATH_NAME_RE.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 ───────────────────────────────────────────────── // // #95 — Conversations are persisted server-side // (`/api/ai/bookslm/history`), localStorage being kept as an offline cache. // A session is `{id, title, mode, vault, directory, documents, context, // createdAt, updatedAt, messages}`; messages may be `null` while a summary // coming from the server has not been hydrated yet. The header history // button and the sidebar tab (#96) both list every session of the user. _contextKey() { const ctx = this._mode === MODE.DOCUMENTS ? documentsSignature(this._documents) : (this._directory || this._vault || 'general'); return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`; } _userScopedKey(base) { let user = ''; try { const u = AuthManager.getUser ? AuthManager.getUser() : null; if (u && u.username) user = u.username; } catch { /* ignore */ } return `${base}-${user || 'anon'}`; } _sessionsKey() { return this._userScopedKey('bookslm-sessions-all'); } _legacySessionsKey() { 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: '', mode: this._mode, vault: this._vault, directory: this._directory, documents: Array.isArray(this._documents) ? this._documents : [], context: this._contextKey(), createdAt: now, updatedAt: now, messages: messages || [], }; } _currentSession() { return this._sessions.find((s) => s.id === this._currentSessionId) || null; } _normalizeSession(s) { const messages = Array.isArray(s.messages) ? s.messages : (s.messages == null ? null : []); return { id: s.id || this._newSessionId(), title: s.title || '', mode: s.mode || MODE.GENERAL, vault: s.vault || null, directory: s.directory || '', documents: Array.isArray(s.documents) ? s.documents : [], context: s.context || '', createdAt: s.createdAt || Date.now(), updatedAt: s.updatedAt || s.createdAt || Date.now(), messages, }; } /** Insert or update `session`, keeping the list sorted by updatedAt. */ _positionSession(session) { let replaced = false; for (let i = 0; i < this._sessions.length; i++) { if (this._sessions[i].id === session.id) { this._sessions[i] = session; replaced = true; break; } } if (!replaced) this._sessions.push(session); this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); } /** Read the offline cache (localStorage). */ _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') .map((s) => this._normalizeSession(s)); } catch { return []; } } /** Import the pre-#95 per-context keys into the global store, once. */ _migrateLegacyHistory() { const migrated = []; try { const raw = localStorage.getItem(this._legacySessionsKey()); if (raw) { const list = JSON.parse(raw); if (Array.isArray(list)) { for (const s of list) { if (!s || typeof s !== 'object') continue; if (!s.context) s.context = this._contextKey(); if (!s.mode) s.mode = this._mode; migrated.push(this._normalizeSession(s)); } } localStorage.removeItem(this._legacySessionsKey()); } } catch { /* ignore */ } try { const raw = localStorage.getItem(this._legacyHistoryKey()); if (raw) { const messages = JSON.parse(raw); if (Array.isArray(messages) && messages.length) { const session = this._createSession(messages); session.title = this._deriveTitle(messages); migrated.push(session); } localStorage.removeItem(this._legacyHistoryKey()); } } catch { /* ignore */ } return migrated; } // ── Server round-trips (#95) ──────────────────────────────────────── async _fetchHistorySummaryFromServer() { const data = await api('/api/ai/bookslm/history'); return Array.isArray(data.sessions) ? data.sessions : []; } async _fetchSessionFromServer(id) { try { return await api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`); } catch { return null; } } _persistableMessages(messages) { return (messages || []).map(({ confirmation, payload, ...rest }) => rest); } async _upsertSessionToServer(session) { const payload = { id: session.id, title: session.title || '', mode: session.mode || MODE.GENERAL, vault: session.vault ?? null, directory: session.directory || '', documents: Array.isArray(session.documents) ? session.documents : [], context: session.context || '', createdAt: session.createdAt, updatedAt: session.updatedAt, messages: this._persistableMessages(session.messages), }; return api(`/api/ai/bookslm/history/${encodeURIComponent(payload.id)}`, { method: 'PUT', body: JSON.stringify(payload), }); } async _deleteSessionFromServer(id) { return api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`, { method: 'DELETE' }); } /** Debounced best-effort push of local changes to the server. */ _syncHistoryToServer() { if (this._syncTimer) clearTimeout(this._syncTimer); this._syncTimer = setTimeout(() => { this._syncTimer = null; this._flushHistoryToServer(); }, 600); } async _flushHistoryToServer() { if (this._syncInFlight) return; this._syncInFlight = true; try { for (const id of [...this._deletedIds]) { try { await this._deleteSessionFromServer(id); this._deletedIds.delete(id); } catch { /* offline: retry next time */ } } for (const session of this._sessions) { if (!this._dirtySessionIds.has(session.id)) continue; try { await this._upsertSessionToServer(session); this._dirtySessionIds.delete(session.id); } catch { /* offline: keep dirty, retry next time */ } } } finally { this._syncInFlight = false; this._notifyHistoryChanged(); } } _persistLocalCache() { try { localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions)); } catch { /* quota / private mode */ } } /** Local cache + schedule the server sync (#95). */ _persistSessions() { this._persistLocalCache(); this._syncHistoryToServer(); this._notifyHistoryChanged(); } /** Broad(cast) notification consumed by the sidebar history tab (#96). */ _notifyHistoryChanged() { try { window.dispatchEvent(new CustomEvent('bookslm:history-updated')); } catch { /* ignore */ } } async _loadHistory(preferredSessionId = null) { // 1. Local cache first (fast, offline-safe). this._sessions = this._readSessions(); for (const migrated of this._migrateLegacyHistory()) this._positionSession(migrated); // 2. Merge the server list (authoritative metadata); sessions present // locally but unknown to the server are marked dirty and re-uploaded. try { const remote = await this._fetchHistorySummaryFromServer(); if (remote.length) { const remoteIds = new Set(); const remoteBy = new Map(); for (const r of remote) { remoteIds.add(r.id); remoteBy.set(r.id, r); } for (const s of this._sessions) { if (!remoteIds.has(s.id)) this._dirtySessionIds.add(s.id); } for (const r of remote) { if (remoteBy.has(r.id) && this._sessions.some((s) => s.id === r.id)) continue; this._positionSession(this._normalizeSession({ ...r, messages: null })); } this._persistLocalCache(); } } catch { /* offline: keep the localStorage cache */ } 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; const created = this._createSession([]); this._positionSession(created); this._dirtySessionIds.add(created.id); this._persistSessions(); } else if (!this._sessions.length) { const created = this._createSession([]); this._positionSession(created); this._dirtySessionIds.add(created.id); this._persistSessions(); } // 3. Pick the session to display: the requested one, else the latest of // the current context, else any conversation. let current = preferredSessionId ? this._sessions.find((s) => s.id === preferredSessionId) || null : this._sessions.find((s) => s.context === this._contextKey()) || this._sessions[0] || null; if (!current) { current = this._createSession([]); this._positionSession(current); } else if (!Array.isArray(current.messages)) { const full = await this._fetchSessionFromServer(current.id); current.messages = full && Array.isArray(full.messages) ? full.messages : []; this._persistLocalCache(); } this._currentSessionId = current.id; this._messages = current.messages || []; this._notifyHistoryChanged(); } _saveHistory() { const persisted = this._persistableMessages(this._messages); const session = this._currentSession(); if (session) { session.messages = persisted; session.updatedAt = Date.now(); if (!session.title) session.title = this._deriveTitle(this._messages); this._dirtySessionIds.add(session.id); this._positionSession(session); } else { const created = this._createSession(persisted); created.title = this._deriveTitle(this._messages); this._positionSession(created); this._currentSessionId = created.id; this._dirtySessionIds.add(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; } /** Select a session, hydrating it from the server when only a summary. */ async _selectSessionById(id) { let session = this._sessions.find((s) => s.id === id) || null; if (!session || !Array.isArray(session.messages)) { const fetched = await this._fetchSessionFromServer(id); if (fetched && Array.isArray(fetched.messages)) { session = this._normalizeSession(fetched); this._positionSession(session); } else if (session) { session.messages = session.messages || []; } } if (!session) return; this._currentSessionId = session.id; this._messages = Array.isArray(session.messages) ? session.messages : []; this._closeHistoryMenu(); if (this._panel) { this._updateHeader(); this._renderMessages({ anchor: true }); this._showSuggestions(); this._renderHistoryMenu(); } this._persistLocalCache(); } async _loadSession(id) { await this._selectSessionById(id); } _deleteSession(id) { this._sessions = this._sessions.filter((s) => s.id !== id); this._dirtySessionIds.delete(id); this._deletedIds.add(id); let session; if (!this._sessions.length) { session = this._createSession([]); this._sessions.push(session); this._dirtySessionIds.add(session.id); } else if (id === this._currentSessionId) { session = this._sessions[0]; } else { session = this._currentSession() || this._sessions[0]; } this._currentSessionId = session.id; this._messages = session.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 meta = document.createElement('span'); meta.className = 'bookslm-history-meta'; const label = this._historyContextLabel(session); const date = this._formatDate(session.updatedAt); meta.textContent = label ? `${label} · ${date}` : date; open.appendChild(title); open.appendChild(meta); 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 ''; } } /** Short context reachable from a session, used by the history menu. */ _historyContextLabel(session) { if (!session) return ''; const mode = session.mode || MODE.GENERAL; if (mode === MODE.DIRECTORY) { if (session.directory) return `📁 ${session.directory}`; if (session.vault) return `📁 ${session.vault}`; return t('bookslm.mode_directory'); } if (mode === MODE.DOCUMENTS) return t('bookslm.mode_documents'); return t('bookslm.mode_general'); } } const booksLM = new BooksLM(); export default booksLM; export { BooksLM };