// ObsiGate — AI Assistant sidebar. // // The panel adapts to three contexts: // • directory — a vault folder (opened from the tree context menu) // • documents — the file(s) currently open in tabs/panes // • general — no document open: app help + file/directory creation actions import { t } from './i18n.js'; import { buildAIPickerUI } from './ai.js'; import { safeCreateIcons } from './utils.js'; import { api, AuthManager } from './auth.js'; import { showToast } from './ui.js'; import { state } from './state.js'; export const MODE = Object.freeze({ DIRECTORY: 'directory', DOCUMENTS: 'documents', GENERAL: 'general', }); // File extensions recognised when turning a mention into a clickable link. // Anything else that looks like a path is treated as a directory (revealed // in the sidebar tree instead of being opened in the viewer). const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i; // A path-like token: optional `dir/` segments followed by a final segment. const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g; /** * Gather the files currently open across the singleton TabManager and every * split-view pane (window globals avoid a circular import with ui.js). * The active tab of each pane is listed first. */ export function collectOpenDocuments() { const docs = []; const seen = new Set(); const add = (vault, path) => { if (!vault || !path) return; const key = `${vault}\u0000${path}`; if (seen.has(key)) return; seen.add(key); docs.push({ vault, path }); }; const scan = (tm) => { if (!tm || !Array.isArray(tm._tabs)) return; const active = tm._tabs.find((x) => x.id === tm._activeTabId); if (active) add(active.vault, active.path); for (const tab of tm._tabs) add(tab.vault, tab.path); }; try { scan(window.TabManager); const pm = window.PaneManager; if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan); } catch { /* not ready yet */ } return docs; } export function documentsSignature(documents) { return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000'); } class BooksLM { constructor() { this._isOpen = false; this._mode = MODE.GENERAL; this._vault = null; this._directory = null; this._documents = []; this._effectiveScope = MODE.GENERAL; this._messages = []; this._contextFiles = []; this._isLoading = false; this._abortCtrl = null; this._panel = null; this._isFullscreen = false; this._sessions = []; this._currentSessionId = null; this._pendingNewSession = false; // Agent mode: routes chat through /api/ai/bookslm/agent so the model can // call read/search tools and propose mutations (confirmation cards). this._agentMode = this._readAgentMode(); } _readAgentMode() { try { return localStorage.getItem('obsigate-bookslm-agent') === 'true'; } catch { return false; } } _toggleAgentMode() { this._agentMode = !this._agentMode; try { localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false'); } catch { /* private mode */ } this._updateAgentToggle(); } _updateAgentToggle() { if (!this._panel) return; const btn = this._panel.querySelector('.bookslm-btn-agent'); if (!btn) return; btn.classList.toggle('active', this._agentMode); const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off'); btn.title = label; btn.setAttribute('aria-label', label); btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false'); } // ── Public API ────────────────────────────────────────────────────── /** * Open the assistant for a specific context. * @param {object} opts * @param {'directory'|'documents'|'general'} opts.mode * @param {string|null} [opts.vault] * @param {string} [opts.directory] * @param {Array<{vault:string,path:string}>} [opts.documents] */ async openContext(opts = {}) { const mode = opts.mode || MODE.GENERAL; const vault = opts.vault || null; const directory = opts.directory || ''; const documents = Array.isArray(opts.documents) ? opts.documents : []; const sameContext = this._isOpen && this._mode === mode && this._vault === vault && this._directory === directory && documentsSignature(this._documents) === documentsSignature(documents); if (sameContext) { // Already open for this context: reveal a collapsed panel, else close. if (this._panel && this._panel.classList.contains('hidden')) { this._setHidden(false); document.dispatchEvent(new CustomEvent('bookslm:opened')); } else { this.close(); } return; } this._mode = mode; this._vault = vault; this._directory = directory; this._documents = documents; this._effectiveScope = mode; this._messages = []; this._contextFiles = []; this._isLoading = true; this._loadHistory(); if (!this._panel) { this._panel = this._render(); document.body.appendChild(this._panel); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } // An explicit open always reveals the panel, even if it was collapsed // in a previous session (previously it stayed hidden → "ne s'ouvre plus"). this._setHidden(false); this._updateHeader(); this._updateStatus(); this._renderMessages(); this._showSuggestions(); this._closeHistoryMenu(); this._renderHistoryMenu(); requestAnimationFrame(() => { this._panel.classList.add('open'); document.dispatchEvent(new CustomEvent('bookslm:opened', { detail: { mode, vault, directory, documents }, })); }); this._isOpen = true; await this._loadContext(); this._isLoading = false; } /** Backwards-compatible helper: open the assistant on a directory. */ async open(vault, directory) { return this.openContext({ mode: MODE.DIRECTORY, vault, directory }); } /** * Open the assistant using the current application context: the open * document(s) if any, otherwise the General assistant. */ async openForCurrentContext() { const docs = collectOpenDocuments(); if (docs.length) { const vault = docs[0].vault; const scoped = docs.filter((d) => d.vault === vault); return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped }); } return this.openContext({ mode: MODE.GENERAL }); } close() { if (!this._panel || !this._isOpen) return; this._panel.classList.remove('open'); this._isOpen = false; document.dispatchEvent(new CustomEvent('bookslm:closed')); if (this._abortCtrl) { this._abortCtrl.abort(); this._abortCtrl = null; } } _setHidden(hidden) { if (!this._panel) return; this._panel.classList.toggle('hidden', hidden); try { localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false'); } catch { /* */ } this._updateToggleIcon(); } /** Collapse/expand while keeping the panel mounted (header toggle button). */ _toggleSidebar() { if (!this._panel) return; const isHidden = !this._panel.classList.contains('hidden'); this._setHidden(isHidden); document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened')); } _updateToggleIcon() { if (!this._panel) return; const btn = this._panel.querySelector('.bookslm-btn-toggle'); if (!btn) return; const isHidden = this._panel.classList.contains('hidden'); const icon = btn.querySelector('i[data-lucide]'); if (!icon) return; icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close'); if (typeof safeCreateIcons === 'function') safeCreateIcons(); } newConversation() { // Called before the panel exists (command palette): defer the fresh // session until the assistant opens for its actual context. if (!this._panel) { this._pendingNewSession = true; this._messages = []; return; } const session = this._createSession([]); this._sessions.unshift(session); this._currentSessionId = session.id; this._messages = session.messages; this._persistSessions(); this._renderMessages(); this._showSuggestions(); this._renderHistoryMenu(); } exportConversation() { if (!this._messages.length) return; const current = this._currentSession(); const label = (current && current.title) || this._contextTitle() || 'assistant'; let md = `# ${label}\n\n`; for (const msg of this._messages) { const role = msg.role === 'user' ? '**You**' : '**AI**'; md += `### ${role}\n\n${msg.content}\n\n---\n\n`; } const blob = new Blob([md], { type: 'text/markdown' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`; a.click(); URL.revokeObjectURL(a.href); } openFile(path) { return this._openFileLink(path); } /** Resolve the vault used to open/reveal a link mentioned by the assistant. */ _resolveVault() { if (this._vault) return this._vault; try { const docs = collectOpenDocuments(); if (docs.length) return docs[0].vault; } catch { /* not ready */ } return state.currentVault || null; } /** Open a file mentioned in an assistant answer in the main viewer. */ async _openFileLink(path) { const vault = this._resolveVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; } window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault, path } })); try { const m = await import('./viewer.js'); if (m && typeof m.openFile === 'function') await m.openFile(vault, path); } catch (e) { console.warn('AI assistant: open file link failed', e); } // Reposition the tree indicator on the opened file (best-effort). try { const s = await import('./sidebar.js'); if (s && typeof s.focusPathInSidebar === 'function') { await s.focusPathInSidebar(vault, path, { alignToTop: true }); } } catch (e) { console.warn('AI assistant: reveal file in tree failed', e); } } /** Reveal a directory (or file) path in the sidebar tree. */ async _revealPath(path) { const vault = this._resolveVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; } window.dispatchEvent(new CustomEvent('obsigate:reveal-in-tree', { detail: { vault, path } })); try { const m = await import('./sidebar.js'); if (m && typeof m.focusPathInSidebar === 'function') { await m.focusPathInSidebar(vault, path, { alignToTop: true, expandTarget: true }); } } catch (e) { console.warn('AI assistant: reveal path failed', e); } } // ── Context ───────────────────────────────────────────────────────── async _loadContext() { if (this._mode === MODE.GENERAL) { this._effectiveScope = MODE.GENERAL; this._contextFiles = []; this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 }); this._showSuggestions(); return; } const payload = { mode: this._mode, vault: this._vault, directory: this._directory, context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [], }; try { const data = await api('/api/ai/bookslm/context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); this._contextFiles = data.files || []; this._effectiveScope = data.scope || this._mode; this._updateStatus(data); this._showSuggestions(); } catch (e) { console.warn('AI assistant: context load failed', e); this._contextFiles = []; this._updateStatus({ error: e.message }); } } _contextMeta() { if (this._mode === MODE.DOCUMENTS) { const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean); return { icon: 'file-text', title: t('ai.context_documents', { count: this._documents.length }), subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''), }; } if (this._mode === MODE.DIRECTORY) { const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault; return { icon: 'folder', title: name || this._vault || t('bookslm.title'), subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`, }; } return { icon: 'sparkles', title: t('ai.context_general'), subtitle: t('ai.context_general_desc'), }; } _contextTitle() { return this._contextMeta().title; } // ── Rendering ─────────────────────────────────────────────────────── _render() { const panel = document.createElement('div'); panel.className = 'bookslm-panel'; panel.innerHTML = `
${this._renderDiffLines([{ t: '+', s: proposed }])}`;
return;
}
const lines = this._lineDiff(current, proposed);
conf._diffHtml = title + `${this._renderDiffLines(lines)}`;
}
async _fetchCurrentContent(vault, path) {
const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`);
return data && typeof data.raw === 'string' ? data.raw : '';
}
/** Minimal line diff (LCS) between the current and the proposed content. */
_lineDiff(oldText, newText) {
const a = String(oldText || '').split('\n');
const b = String(newText || '').split('\n');
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; }
else { out.push({ t: '+', s: b[j] }); j++; }
}
while (i < n) out.push({ t: '-', s: a[i++] });
while (j < m) out.push({ t: '+', s: b[j++] });
return out;
}
_renderDiffLines(lines) {
const cap = 400;
const shown = lines.length > cap ? lines.slice(0, cap) : lines;
let html = shown.map((l) => {
const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx');
return `${this._escapeHtml(l.t + ' ' + l.s)}`;
}).join('\n');
if (lines.length > cap) {
html += `\n… ${lines.length - cap}`;
}
return html;
}
async _applyConfirmation(msg) {
const conf = msg.confirmation;
if (!conf || conf._applying) return;
conf._applying = true;
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await this._postChat(payload);
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
// The confirmation is resolved: drop the card and show the continuation.
msg.confirmation = null;
this._messages.push(continuation);
await this._streamResponse(resp, continuation, payload);
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._renderMessages();
this._saveHistory();
}
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
const textarea = this._panel.querySelector('textarea');
const text = (textarea.value || '').trim();
if (!text || this._isLoading) return;
textarea.value = '';
textarea.style.height = 'auto';
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Snapshot prior turns BEFORE appending the new user message.
const history = this._messages
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content }));
this._messages.push({ role: 'user', content: text });
this._renderMessages();
let provider = null;
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} catch { /* */ }
const payload = {
mode: this._mode,
vault: this._vault,
directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
message: text,
conversation_history: history,
provider,
model,
};
const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await this._postChat(payload);
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
await this._streamResponse(resp, assistantMsg, payload);
if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase();
assistantMsg.sources = this._contextFiles
.filter((f) => {
const name = (f.path || f || '').split('/').pop().toLowerCase();
return name && contentLower.includes(name);
})
.map((f) => f.path || f);
}
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
}
/** POST to /chat or /agent depending on the agent-mode toggle. */
_postChat(payload) {
const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl ? this._abortCtrl.signal : undefined,
});
}
/**
* Consume an SSE response, feeding tokens/tool/confirmation events into
* `assistantMsg`. Shared by the initial send and the confirmation resume.
*/
async _streamResponse(resp, assistantMsg, payload) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'tool') {
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
return;
}
if (currentEvent === 'confirmation') {
assistantMsg.confirmation = {
pending: data.pending || data,
messages: data.messages || [],
};
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
}
// ── Markdown (self-contained renderer) ──────────────────────────────
//
// Renders the assistant answer as formatted HTML (headings, lists, tables,
// blockquotes, code, emphasis) without any external dependency. All model
// text is HTML-escaped before markup is produced, and file/path mentions
// become clickable links handled by the delegated listener in `_render()`.
_renderMarkdown(text) {
if (!text) return '';
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
const out = [];
const para = [];
let i = 0;
const flushPara = () => {
if (!para.length) return;
out.push(`${this._renderInline(para.join('\n'))}
`); para.length = 0; }; while (i < lines.length) { const line = lines[i]; // Fenced code block. const fence = line.match(/^\s*```\s*([\w+#-]*)\s*$/); if (fence) { flushPara(); const lang = fence[1] || ''; const code = []; i++; while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i++; } i++; // closing fence out.push(this._renderCodeBlock(lang, code.join('\n'))); continue; } // ATX heading. const heading = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/); if (heading) { flushPara(); const level = heading[1].length; out.push(`${this._renderMarkdown(quote.join('\n'))}`); continue; } // Table. if (line.includes('|') && i + 1 < lines.length && this._isTableSeparator(lines[i + 1])) { flushPara(); const table = this._renderTable(lines, i); out.push(table.html); i = table.next; continue; } // List. if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { flushPara(); const list = this._renderList(lines, i); out.push(list.html); i = list.next; continue; } if (!line.trim()) { flushPara(); i++; continue; } para.push(line); i++; } flushPara(); return out.join('\n'); } /** Render the inline (span-level) markup of a single line or paragraph. */ _renderInline(text) { if (text == null) return ''; const stash = []; const put = (html) => { const idx = stash.length; stash.push(html); return `\u0000${idx}\u0000`; }; let s = this._escapeHtml(String(text)); // Inline code — a path wrapped in backticks becomes a clickable link. s = s.replace(/`([^`\n]+)`/g, (_m, code) => { const raw = this._decodeEntities(code); if (this._looksLikePath(raw)) return put(`
${this._pathLink(code, raw)}`);
return put(`${code}`);
});
// Explicit markdown links: [label](target).
s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) =>
put(this._renderMdLink(label, target)));
// Bare `dir/file.ext` mentions.
s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) =>
`${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`);
// Emphasis.
s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1');
s = s.replace(/__([^_\n]+)__/g, '$1');
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2');
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1$2');
s = s.replace(/~~([^~\n]+)~~/g, '${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) => `