// 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 = `
${t('ai.create_skill')}
`;
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);
this._enhanceCodeBlocks(bubble);
// 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 (or a single code block) to the document open
* in the active editor (Notion “Ajouter”). Supports the three editor
* surfaces: CodeMirror (« Editer »), the Forge iframe and the plain textarea
* fallback. No-ops with a toast when no editor session is open.
*/
_insertIntoEditor(text) {
const value = String(text == null ? '' : text);
if (!value.trim()) return;
// 1. CodeMirror editor (« Editer »).
const view = state.editorView;
if (view && view.state && typeof view.dispatch === 'function') {
try {
const at = view.state.selection.main.to;
const insert = (at > 0 ? '\n' : '') + value;
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');
}
return;
}
// 2. Forge editor (same-origin iframe): its buffer lives in the child
// document, so the insertion is delegated with a postMessage.
const forge = document.getElementById('forge-iframe');
if (forge && forge.contentWindow) {
try {
forge.contentWindow.postMessage({ type: 'parent-insert', text: value }, '*');
showToast(t('bookslm.inserted'), 'success');
} catch (e) {
console.warn('AI assistant: insert into Forge failed', e);
showToast(t('bookslm.insert_no_editor'), 'error');
}
return;
}
// 3. Plain textarea fallback (CodeMirror failed to load).
const ta = state.fallbackEditorEl;
if (ta && typeof ta.value === 'string') {
const start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
const end = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length;
const insert = (start > 0 ? '\n' : '') + value;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
const pos = start + insert.length;
if (typeof ta.setSelectionRange === 'function') ta.setSelectionRange(pos, pos);
ta.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof ta.focus === 'function') ta.focus();
showToast(t('bookslm.inserted'), 'success');
return;
}
showToast(t('bookslm.insert_no_editor'), 'info');
}
/**
* #102 — Add a discreet « Ajouter » button to every fenced code block of an
* answer, so a single proposed section can be inserted instead of the whole
* reply. The button reuses the message action styling.
*/
_enhanceCodeBlocks(bubble) {
if (!bubble || typeof bubble.querySelectorAll !== 'function') return;
const blocks = bubble.querySelectorAll('pre');
blocks.forEach((pre) => {
const code = pre.querySelector('code');
if (!code) return;
const wrap = document.createElement('div');
wrap.className = 'bookslm-code-block';
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(pre);
const btn = this._actionBtn(
t('bookslm.insert_block'),
'insert',
t('bookslm.insert_block_hint'),
() => this._insertIntoEditor(code.textContent),
);
btn.classList.add('bookslm-code-insert');
wrap.appendChild(btn);
});
}
_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 + `
`;
}
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 `