Files
ObsiGate/frontend/js/bookslm.js
T
bruno 778fa65b4c fix: assistant IA toujours en mode agent, retrait du bouton toggle #187
- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
2026-10-07 08:16:56 -04:00

4084 lines
152 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
// #106 — contextual quick actions (catalogue + triage rules live in ai-quick-actions.js).
import {
detectContext,
suggestionsFor,
actionTexts,
contextBadgeKey,
ACTION_CATALOG,
CATEGORIES,
} from './ai-quick-actions.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';
// BUG-076 — Tools that mutate the vault: their execution must refresh the
// sidebar tree immediately (the watcher SSE is delayed and directory-only
// changes may not produce an index event).
const MUTATING_TOOLS = new Set([
'create_file', 'create_directory', 'edit_file', 'append_to_file',
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
/**
* BUG-059 — Is this pointer press a scrollbar drag (and only that)?
*
* Only genuine scroll gestures may release the top-pinning of the latest
* question (wheel / touchmove / scrollbar drag). A scrollbar drag reports the
* scrollable container itself as the event target and lands inside the
* vertical scrollbar gutter. A plain click anywhere in the content — a link,
* a file path, the steps toggle, text selection — must NOT unpin: clearing
* the anchor padding would clamp the scroll position and jump the whole
* thread to the bottom of the conversation window.
*/
export function isScrollbarPress(target, clientX, container) {
if (!container || target !== container) return false;
const rect = container.getBoundingClientRect();
if (!rect || !Number.isFinite(rect.right)) return false;
const GUTTER = 24; // conservative vertical-scrollbar width estimate
return clientX >= rect.right - GUTTER;
}
/**
* 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;
// #187: the assistant is always in agent mode (tools on /agent endpoint);
// the former header toggle was removed — images still use the /chat path.
// 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;
}
// ── 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 / BUG-076 — 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;
if (!FILE_WRITE_TOOLS.has(data.name)) 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 },
}),
);
}
/**
* BUG-076 — Refresh the sidebar tree right after an agent mutation, without
* waiting for the (debounced) watcher SSE. Debounced so a batch of tool
* events (e.g. a folder plus its files) triggers a single refresh.
*/
_scheduleTreeRefresh() {
if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer);
this._treeRefreshTimer = setTimeout(async () => {
this._treeRefreshTimer = null;
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}, 250);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
const panel = document.createElement('div');
panel.className = 'bookslm-panel';
panel.innerHTML = `
<div class="bookslm-resize-handle" role="separator" aria-orientation="vertical" title="${t('ai.resize_panel')}" aria-label="${t('ai.resize_panel')}"></div>
<div class="bookslm-header">
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
</button>
<span class="bookslm-context-icon"><i data-lucide="sparkles" style="width:16px;height:16px"></i></span>
<div class="bookslm-header-titles">
<span class="bookslm-title"></span>
<span class="bookslm-subtitle"></span>
</div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions">
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-fullscreen" title="${t('ai.fullscreen')}" aria-label="${t('ai.fullscreen')}"><i data-lucide="maximize-2" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-close" title="${t('ai.close')}" aria-label="${t('ai.close')}"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
</div>
<div class="bookslm-history-menu hidden"></div>
<div class="bookslm-toolbar">
<span class="bookslm-picker-host ai-picker-slot"></span>
</div>
<div class="bookslm-status"></div>
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<div class="bookslm-attachments"></div>
<div class="bookslm-menu-layer">
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
</div>
<div class="bookslm-input-area">
<button class="bookslm-btn-plus" title="${t('bookslm.add_options')}" aria-label="${t('bookslm.add_options')}"><i data-lucide="plus" style="width:18px;height:18px"></i></button>
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
<input type="file" class="bookslm-files-input" multiple hidden>
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('ai.composer_label')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">
<i data-lucide="arrow-up" style="width:18px;height:18px"></i>
</button>
</div>
<div class="bookslm-ext-menu hidden"></div>
`;
// 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-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 });
// BUG-059: a scrollbar drag only — a plain click on the content must
// not unpin (it would clear the anchor padding and jump the thread to
// the bottom of the window).
messagesEl.addEventListener('mousedown', (e) => {
if (isScrollbarPress(e.target, e.clientX, messagesEl)) 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', () => {
// BUG-077: the same button stops the run while a response streams.
if (this._isLoading) this._stopGeneration();
else 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));
// #106 — The suggested row flips to "selection" actions while the user
// selects text in the open editor; refresh (throttled) on selectionchange.
let qaSelTimer = null;
const onSelectionChange = () => {
if (qaSelTimer) return;
qaSelTimer = setTimeout(() => {
qaSelTimer = null;
if (!this._panel || this._panel.classList.contains('hidden')) return;
if (this._messages.length) return;
const ctx = this._quickContext();
if (ctx !== this._qaContext) this._showSuggestions();
}, 250);
};
document.addEventListener('selectionchange', onSelectionChange);
// CodeMirror owns its selection (no native selectionchange on drag), so
// the same throttled refresh is bound to pointer/keyboard release too.
document.addEventListener('mouseup', onSelectionChange);
document.addEventListener('keyup', onSelectionChange);
// 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 = `
<i data-lucide="${entry.icon}" style="width:16px;height:16px"></i>
<span class="bookslm-ext-label">${entry.label}</span>
${entry.disabled ? '<span class="bookslm-ext-soon">' + t('bookslm.soon') + '</span>' : ''}
<span class="bookslm-ext-hint">${entry.hint}</span>
`;
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: a skill-like chip (the agent tools are always on). */
_startDeepResearch() {
this._closeExtMenu();
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 <nom>\``,
);
}
_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 <nom>\``,
);
}
/** 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 = `
<div class="bookslm-modal" role="dialog" aria-modal="true" aria-label="${t('ai.create_skill')}">
<h3 class="bookslm-modal-title">${t('ai.create_skill')}</h3>
<label class="bookslm-modal-field">${t('ai.skill_id')}
<input class="bookslm-skill-id" placeholder="mon-skill">
</label>
<label class="bookslm-modal-field">${t('ai.skill_label')}
<input class="bookslm-skill-label" placeholder="${t('ai.skill_label')}">
</label>
<label class="bookslm-modal-field">${t('ai.skill_prompt')}
<textarea class="bookslm-skill-prompt" rows="4" placeholder="${t('ai.skill_prompt_placeholder')}"></textarea>
</label>
<div class="bookslm-modal-error"></div>
<div class="bookslm-modal-actions">
<button type="button" class="bookslm-modal-cancel">${t('button.cancel')}</button>
<button type="button" class="bookslm-modal-ok primary">${t('common.save')}</button>
</div>
</div>`;
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 = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
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 = `
<span class="bookslm-status-text">${label}</span>
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
`;
} else if (this._isLoading) {
status.classList.remove('hidden');
status.textContent = '⏳ …';
}
}
// ── #106 — Actions instantanées contextuelles ───────────────────────
//
// The welcome zone above the composer shows the 3 top actions for the
// *detected* context (selection > code > multi-doc > single-doc >
// directory > general, rules in ai-quick-actions.js); the full catalogue
// stays reachable through the "Toutes les actions" drawer.
/** True when the open editor (CM6, or the mobile textarea fallback)
* holds a non-empty text selection. Best-effort, never throws. */
_hasEditorSelection() {
try {
const view = state.editorView;
if (view && view.state && view.state.selection) {
const sel = view.state.selection.main;
if (sel && sel.from !== sel.to) return true;
}
const ta = state.fallbackEditorEl;
if (ta && document.body.contains(ta)
&& ta.selectionStart != null && ta.selectionEnd > ta.selectionStart) {
return true;
}
} catch { /* editor not ready */ }
return false;
}
/** The live context key driving the suggested-actions row. */
_quickContext() {
const docs = this._mode === MODE.DOCUMENTS ? this._documents : [];
return detectContext({
mode: this._mode,
docCount: docs.length,
currentPath: docs.length ? (docs[0].path || null) : null,
hasSelection: this._hasEditorSelection(),
});
}
/** Header chip describing the detected context ("1 doc ouvert", …). */
_updateQaBadge() {
if (!this._panel) return;
const badge = this._panel.querySelector('.bookslm-qa-badge');
if (!badge) return;
const ctx = this._qaContext || this._quickContext();
const docCount = this._mode === MODE.DOCUMENTS ? this._documents.length : 0;
badge.textContent = t(contextBadgeKey(ctx, docCount), { count: docCount });
badge.dataset.qaContext = ctx;
badge.classList.remove('hidden');
}
_showSuggestions() {
if (!this._panel) return;
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (!sugEl) return;
sugEl.innerHTML = '';
if (this._messages.length) {
sugEl.classList.add('hidden');
return;
}
sugEl.classList.remove('hidden');
const ctx = this._quickContext();
this._qaContext = ctx;
this._updateQaBadge();
// Welcome hint (mirrors the POC copy: actions or free question).
const hint = document.createElement('p');
hint.className = 'bookslm-qa-hint';
hint.textContent = t('qa.empty_hint');
sugEl.appendChild(hint);
// Row head: label + access to the full catalogue.
const head = document.createElement('div');
head.className = 'bookslm-qa-head';
const label = document.createElement('span');
label.className = 'bookslm-qa-label';
label.textContent = t('qa.header_suggested');
head.appendChild(label);
const more = document.createElement('button');
more.type = 'button';
more.className = 'bookslm-qa-more';
more.innerHTML = `<span>${t('qa.all_actions')}</span>`
+ '<i data-lucide="layout-grid" style="width:13px;height:13px"></i>';
more.addEventListener('click', (e) => {
e.stopPropagation();
this._toggleActionDrawer();
});
head.appendChild(more);
sugEl.appendChild(head);
const list = document.createElement('div');
list.className = 'bookslm-qa-list';
for (const action of suggestionsFor(ctx)) {
list.appendChild(this._renderQuickActionBtn(action));
}
sugEl.appendChild(list);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** One action button (icon + label + hover arrow), used in both the row
* and the drawer catalogue. */
_renderQuickActionBtn(action, opts = {}) {
const { label, prompt } = actionTexts(action);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bookslm-qa-btn' + (opts.compact ? ' compact' : '');
btn.dataset.qaAction = action.id;
btn.innerHTML = `
<i data-lucide="${action.icon}" class="bookslm-qa-icon" style="width:15px;height:15px"></i>
<span class="bookslm-qa-text">${label}</span>
<i data-lucide="arrow-right" class="bookslm-qa-go" style="width:14px;height:14px"></i>
`;
btn.title = prompt;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._runQuickAction(action);
});
return btn;
}
/** Immediate-send the action prompt through the composer (same path as a
* typed message: skills and images all keep working). #187: the assistant
* is always agent mode, so the former `agent: true` quick actions need no
* mode switch anymore. */
_runQuickAction(action) {
if (!this._panel || !action) return;
const { prompt } = actionTexts(action);
if (!prompt) return;
this._closeActionDrawer();
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
textarea.value = prompt;
this._sendMessage();
}
// ── Drawer "Toutes les actions" (bottom sheet, searchable) ──────────
_toggleActionDrawer() {
const drawer = this._ensureActionDrawer();
if (!drawer) return;
if (drawer.classList.contains('open')) this._closeActionDrawer();
else this._openActionDrawer();
}
_ensureActionDrawer() {
if (!this._panel) return null;
let drawer = this._panel.querySelector('.bookslm-qa-drawer');
if (drawer) return drawer;
drawer = document.createElement('div');
drawer.className = 'bookslm-qa-drawer hidden';
drawer.innerHTML = `
<div class="bookslm-qa-sheet" role="dialog" aria-modal="true" aria-label="${t('qa.drawer_title')}">
<div class="bookslm-qa-sheet-head">
<span class="bookslm-qa-sheet-title">
<i data-lucide="layout-grid" style="width:15px;height:15px"></i>
${t('qa.drawer_title')}
</span>
<button type="button" class="bookslm-qa-sheet-close" aria-label="${t('ai.close')}">
<i data-lucide="x" style="width:15px;height:15px"></i>
</button>
</div>
<div class="bookslm-qa-search">
<i data-lucide="search" style="width:14px;height:14px"></i>
<input type="text" placeholder="${t('qa.search_placeholder')}" aria-label="${t('qa.search_placeholder')}">
</div>
<div class="bookslm-qa-sheet-list"></div>
</div>
`;
this._panel.appendChild(drawer);
drawer.querySelector('.bookslm-qa-sheet-close').addEventListener('click', () => this._closeActionDrawer());
// Clicking the backdrop (outside the sheet) closes the drawer.
drawer.addEventListener('click', (e) => {
if (e.target === drawer) this._closeActionDrawer();
});
const input = drawer.querySelector('.bookslm-qa-search input');
input.addEventListener('input', () => this._renderActionDrawerList(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this._closeActionDrawer();
}
});
if (typeof safeCreateIcons === 'function') safeCreateIcons();
return drawer;
}
_openActionDrawer() {
const drawer = this._ensureActionDrawer();
if (!drawer) return;
drawer.classList.remove('hidden');
requestAnimationFrame(() => drawer.classList.add('open'));
this._renderActionDrawerList('');
const input = drawer.querySelector('.bookslm-qa-search input');
if (input) { input.value = ''; input.focus(); }
}
_closeActionDrawer() {
if (!this._panel) return;
const drawer = this._panel.querySelector('.bookslm-qa-drawer');
if (!drawer) return;
drawer.classList.remove('open');
// Let the slide-down transition finish before hiding.
setTimeout(() => drawer.classList.add('hidden'), 220);
}
_renderActionDrawerList(filter) {
const drawer = this._panel && this._panel.querySelector('.bookslm-qa-drawer');
if (!drawer) return;
const body = drawer.querySelector('.bookslm-qa-sheet-list');
if (!body) return;
body.innerHTML = '';
const q = normalizeSearch(filter || '');
let shown = 0;
for (const cat of CATEGORIES) {
const actions = ACTION_CATALOG.filter((a) => a.cat === cat.id
&& (!q || normalizeSearch(actionTexts(a).label).includes(q)));
if (!actions.length) continue;
const head = document.createElement('p');
head.className = 'bookslm-qa-cat';
head.innerHTML = `<i data-lucide="${cat.icon}" style="width:13px;height:13px"></i> ${t(cat.labelKey)}`;
body.appendChild(head);
for (const action of actions) {
body.appendChild(this._renderQuickActionBtn(action, { compact: true }));
shown++;
}
}
if (!shown) {
const empty = document.createElement('div');
empty.className = 'bookslm-qa-empty';
empty.textContent = t('qa.no_match');
body.appendChild(empty);
}
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_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 = `<span class="bookslm-action-icon">${isDir ? '📁' : '📄'}</span>`;
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 });
}
/**
* BUG-074 — number of *action* steps of a message (reasoning notes are not
* actions). Falls back to the total when the block holds only thoughts so a
* “0 étape” label can never appear.
*/
_actionStepCount(toolCalls) {
const list = toolCalls || [];
const actions = list.filter((c) => !(c.step && c.step.key === 'thought'));
return actions.length || list.length;
}
/** 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);
}
// BUG-074: the counter reflects *actions*, not reasoning notes, and the
// collapsed header also previews the first action so an “N étapes ▶” line
// is no longer an opaque title.
const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought'));
const count = this._actionStepCount(toolCalls);
const countKey = count > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
const label = document.createElement('span');
label.className = 'bookslm-steps-label';
label.textContent = t(countKey, { count });
summary.appendChild(label);
const first = actionCalls[0];
let preview = '';
if (first) {
preview = this._stepText(first);
const title = document.createElement('span');
title.className = 'bookslm-steps-title';
title.textContent = `— ${preview}`;
summary.appendChild(title);
}
summary.appendChild(this._chevron());
if (running) {
summary.setAttribute(
'aria-label',
`${label.textContent}${preview ? ` — ${preview}` : ''} — ${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 = '<i></i><i></i><i></i>';
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;
// BUG-075: the run batches every mutating call of the LLM turn into
// `pending.actions`; fall back to the single legacy `error` shape.
const actions = (Array.isArray(pending.actions) && pending.actions.length)
? pending.actions
: [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }];
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
const meta = document.createElement('div');
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
if (actions.length > 1) {
textEl.textContent = t('ai.confirm_actions', { count: actions.length });
} else {
const tool = actions[0].tool || 'action';
const args = actions[0].arguments || {};
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
}
meta.appendChild(textEl);
card.appendChild(meta);
// One row per action with a diff preview when it writes file content.
const hasContent = (a) => {
const args = a.arguments || {};
return typeof args.content === 'string' && args.vault && args.path;
};
if (actions.length > 1) {
const list = document.createElement('div');
list.className = 'bookslm-confirm-actions';
for (const action of actions) {
const args = action.arguments || {};
const row = document.createElement('details');
row.className = 'bookslm-confirm-action';
const summary = document.createElement('summary');
const text = document.createElement('span');
text.textContent = this._stepText({ step: action.step, name: action.tool })
+ (args.path ? ` — ${args.path}` : '');
summary.appendChild(text);
if (hasContent(action)) summary.appendChild(this._chevron());
row.appendChild(summary);
if (hasContent(action)) row.appendChild(this._diffHost(action, args));
list.appendChild(row);
}
card.appendChild(list);
} else if (hasContent(actions[0])) {
card.appendChild(this._diffHost(conf, actions[0].arguments || {}));
}
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: 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 = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
card.appendChild(apply);
return card;
}
/** Diff host for one confirmation action (lazy LCS diff, cached on the host). */
_diffHost(host, args) {
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (host._diffHtml) {
diffHost.innerHTML = host._diffHtml;
} else if (!host._diffLoading) {
host._diffLoading = true;
this._fillConfirmationDiff(args, host).then(() => this._renderMessages());
}
return diffHost;
}
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 = `<div class="bookslm-diff-title">${this._escapeHtml(t('ai.diff_preview'))}</div>`;
if (current == null) {
// New file (or unreadable): show everything as an addition.
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines([{ t: '+', s: proposed }])}</pre>`;
return;
}
const lines = this._lineDiff(current, proposed);
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines(lines)}</pre>`;
}
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 `<span class="${cls}">${this._escapeHtml(l.t + ' ' + l.s)}</span>`;
}).join('\n');
if (lines.length > cap) {
html += `\n<span class="bookslm-diff-ctx">… ${lines.length - cap}</span>`;
}
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;
// BUG-075: one click applies the whole pending batch AND authorizes the
// remaining actions of the same run (no second confirmation card).
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
confirm_all: true,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
this._syncSendButton();
this._setActivity('working', t('ai.activity_streaming'));
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 keep streaming into the
// SAME message, so the steps block accumulates the whole exchange
// instead of fragmenting into a new “1 étape” message per approval
// (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays.
msg.confirmation = null;
this._renderMessages({ anchor: true });
await this._streamResponse(resp, msg, payload);
} catch (e) {
if (e.name === 'AbortError') {
this._markStopped(msg);
} else {
throw e;
}
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
this._syncSendButton();
this._renderMessages();
this._saveHistory();
}
}
/** BUG-077 — abort the in-flight agent/chat request. */
_stopGeneration() {
if (this._abortCtrl) {
try {
this._abortCtrl.abort();
} catch { /* already aborted */ }
}
}
/** Append a visible « stopped by the user » marker to an assistant message. */
_markStopped(msg) {
const marker = `⏹ ${t('ai.stopped')}`;
const content = String(msg.content || '');
if (!content.includes(marker)) {
msg.content = content ? `${content}\n\n${marker}` : marker;
}
this._setActivity('idle');
}
/**
* BUG-077 — the composer's round button doubles as a Stop control while a
* response streams: a new send is already blocked by `_isLoading`, so the
* button switches to a stop icon instead of being disabled.
*/
_syncSendButton() {
const btn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (!btn) return;
const loading = !!this._isLoading;
btn.classList.toggle('is-stopping', loading);
btn.title = loading ? t('ai.stop') : t('bookslm.send');
btn.setAttribute('aria-label', btn.title);
const icon = btn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// ── 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 });
this._abortCtrl = new AbortController();
this._syncSendButton();
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') {
this._markStopped(assistantMsg);
} else {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
this._setActivity('error', t('ai.activity_error'));
}
}
this._isLoading = false;
this._syncSendButton();
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
}
/** POST to /agent (always) or /chat when images are attached. */
_postChat(payload) {
// #187: the assistant is always agent mode — images are the only case
// that still needs the plain multimodal chat endpoint (the agent loop is
// text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = hasImages
? '/api/ai/bookslm/chat'
: '/api/ai/bookslm/agent';
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);
// BUG-076 — reflect tree changes (create/delete/rename…) right away.
if (data.ok !== false && MUTATING_TOOLS.has(data.name)) {
this._scheduleTreeRefresh();
}
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(`<p>${this._renderInline(para.join('\n'))}</p>`);
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(`<h${level}>${this._renderInline(heading[2])}</h${level}>`);
i++;
continue;
}
// Horizontal rule.
if (/^\s*(?:[-*_])\s*(?:[-*_]\s*){2,}$/.test(line)) {
flushPara();
out.push('<hr>');
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(`<blockquote>${this._renderMarkdown(quote.join('\n'))}</blockquote>`);
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(`<code>${this._pathLink(code, raw)}</code>`);
return put(`<code>${code}</code>`);
});
// Explicit markdown links: [label](target). The target may contain spaces
// (`Mon dossier/Ma note.md`), an optional <angle-bracket> 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, '<strong>$1</strong>');
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
// Preserve single line breaks (chat answers often rely on them).
s = s.replace(/\n/g, '<br>');
// 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 `<pre><code${cls}>${this._escapeHtml(code)}</code></pre>`;
}
_isTableSeparator(line) {
if (!line || !line.includes('|') || !line.includes('-')) return false;
return line.replace(/[|\s:-]/g, '') === '';
}
_renderTable(lines, start) {
const splitRow = (line) =>
line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
const header = splitRow(lines[start]);
const rows = [];
let i = start + 2;
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
rows.push(splitRow(lines[i]));
i++;
}
const thead = header.map((c) => `<th>${this._renderInline(c)}</th>`).join('');
const tbody = rows
.map((r) => `<tr>${r.map((c) => `<td>${this._renderInline(c)}</td>`).join('')}</tr>`)
.join('');
return { html: `<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`, next: i };
}
_renderList(lines, start) {
const first = lines[start].match(/^(\s*)([-*+]|\d+\.)\s+/);
const baseIndent = first ? first[1].length : 0;
const ordered = first ? /\d+\./.test(first[2]) : false;
const items = [];
let i = start;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) break;
const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
if (!m) break;
const indent = m[1].length;
if (indent < baseIndent) break;
if (indent > baseIndent) {
const nested = this._renderList(lines, i);
if (items.length) items[items.length - 1].children.push(nested.html);
i = nested.next;
continue;
}
let content = m[3];
let checkbox = '';
const task = content.match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
checkbox = `<input type="checkbox" disabled${task[1].toLowerCase() === 'x' ? ' checked' : ''}> `;
content = task[2];
}
items.push({ html: checkbox + this._renderInline(content), children: [] });
i++;
}
const tag = ordered ? 'ol' : 'ul';
const body = items.map((it) => `<li>${it.html}${it.children.join('')}</li>`).join('');
return { html: `<${tag}>${body}</${tag}>`, next: i };
}
_renderMdLink(label, target) {
const isExternal = /^(?:https?:|mailto:|tel:|#|\/\/)/i.test(target);
if (isExternal) {
return `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
// Links emitted by models are sometimes percent-encoded (spaces as %20).
let decoded = this._decodeEntities(target);
if (/%[0-9a-f]{2}/i.test(decoded)) {
try { decoded = decodeURIComponent(decoded); } catch { /* keep as-is */ }
}
return this._pathLink(label, decoded);
}
/** Build a clickable link for a vault-relative file or directory path. */
_pathLink(display, rawPath) {
const clean = this._normalizeLinkPath(this._decodeEntities(rawPath));
const kind = this._classifyPath(clean);
const attr = this._escapeAttr(clean);
return `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
}
_classifyPath(path) {
const p = String(path || '');
// A bare filename (no directory) is copied to the clipboard instead of
// being treated as a vault path — avoids "File not found" dead links.
if (!p.includes('/')) return FILE_EXT_RE.test(p) ? 'name' : 'dir';
return FILE_EXT_RE.test(p) ? 'file' : 'dir';
}
_looksLikePath(value) {
if (value == null) return false;
const p = String(value).trim();
if (!p || /[\n\r]/.test(p)) return false;
// Allow spaces and accented characters inside file/directory names
// (`Ma note.md`, `Recettes/Préparation.md`), but keep the character set
// tight so prose and code snippets are not mistaken for paths.
if (!PATH_NAME_RE.test(p)) return false;
return p.includes('/') || FILE_EXT_RE.test(p);
}
_escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_escapeAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
_decodeEntities(s) {
return String(s)
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
// ── Session history ─────────────────────────────────────────────────
//
// #95 — Conversations are persisted server-side
// (`/api/ai/bookslm/history`), localStorage being kept as an offline cache.
// A session is `{id, title, mode, vault, directory, documents, context,
// createdAt, updatedAt, messages}`; messages may be `null` while a summary
// coming from the server has not been hydrated yet. The header history
// button and the sidebar tab (#96) both list every session of the user.
_contextKey() {
const ctx = this._mode === MODE.DOCUMENTS
? documentsSignature(this._documents)
: (this._directory || this._vault || 'general');
return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
}
_userScopedKey(base) {
let user = '';
try {
const u = AuthManager.getUser ? AuthManager.getUser() : null;
if (u && u.username) user = u.username;
} catch { /* ignore */ }
return `${base}-${user || 'anon'}`;
}
_sessionsKey() {
return this._userScopedKey('bookslm-sessions-all');
}
_legacySessionsKey() {
return `bookslm-sessions-${this._contextKey()}`;
}
_legacyHistoryKey() {
return `bookslm-history-${this._contextKey()}`;
}
_newSessionId() {
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
_createSession(messages) {
const now = Date.now();
return {
id: this._newSessionId(),
title: '',
mode: this._mode,
vault: this._vault,
directory: this._directory,
documents: Array.isArray(this._documents) ? this._documents : [],
context: this._contextKey(),
createdAt: now,
updatedAt: now,
messages: messages || [],
};
}
_currentSession() {
return this._sessions.find((s) => s.id === this._currentSessionId) || null;
}
_normalizeSession(s) {
const messages = Array.isArray(s.messages)
? s.messages
: (s.messages == null ? null : []);
return {
id: s.id || this._newSessionId(),
title: s.title || '',
mode: s.mode || MODE.GENERAL,
vault: s.vault || null,
directory: s.directory || '',
documents: Array.isArray(s.documents) ? s.documents : [],
context: s.context || '',
createdAt: s.createdAt || Date.now(),
updatedAt: s.updatedAt || s.createdAt || Date.now(),
messages,
};
}
/** Insert or update `session`, keeping the list sorted by updatedAt. */
_positionSession(session) {
let replaced = false;
for (let i = 0; i < this._sessions.length; i++) {
if (this._sessions[i].id === session.id) {
this._sessions[i] = session;
replaced = true;
break;
}
}
if (!replaced) this._sessions.push(session);
this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
/** Read the offline cache (localStorage). */
_readSessions() {
try {
const raw = localStorage.getItem(this._sessionsKey());
if (!raw) return [];
const list = JSON.parse(raw);
if (!Array.isArray(list)) return [];
return list
.filter((s) => s && typeof s === 'object')
.map((s) => this._normalizeSession(s));
} catch {
return [];
}
}
/** Import the pre-#95 per-context keys into the global store, once. */
_migrateLegacyHistory() {
const migrated = [];
try {
const raw = localStorage.getItem(this._legacySessionsKey());
if (raw) {
const list = JSON.parse(raw);
if (Array.isArray(list)) {
for (const s of list) {
if (!s || typeof s !== 'object') continue;
if (!s.context) s.context = this._contextKey();
if (!s.mode) s.mode = this._mode;
migrated.push(this._normalizeSession(s));
}
}
localStorage.removeItem(this._legacySessionsKey());
}
} catch { /* ignore */ }
try {
const raw = localStorage.getItem(this._legacyHistoryKey());
if (raw) {
const messages = JSON.parse(raw);
if (Array.isArray(messages) && messages.length) {
const session = this._createSession(messages);
session.title = this._deriveTitle(messages);
migrated.push(session);
}
localStorage.removeItem(this._legacyHistoryKey());
}
} catch { /* ignore */ }
return migrated;
}
// ── Server round-trips (#95) ────────────────────────────────────────
async _fetchHistorySummaryFromServer() {
const data = await api('/api/ai/bookslm/history');
return Array.isArray(data.sessions) ? data.sessions : [];
}
async _fetchSessionFromServer(id) {
try {
return await api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`);
} catch {
return null;
}
}
_persistableMessages(messages) {
return (messages || []).map(({ confirmation, payload, ...rest }) => rest);
}
async _upsertSessionToServer(session) {
const payload = {
id: session.id,
title: session.title || '',
mode: session.mode || MODE.GENERAL,
vault: session.vault ?? null,
directory: session.directory || '',
documents: Array.isArray(session.documents) ? session.documents : [],
context: session.context || '',
createdAt: session.createdAt,
updatedAt: session.updatedAt,
messages: this._persistableMessages(session.messages),
};
return api(`/api/ai/bookslm/history/${encodeURIComponent(payload.id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
async _deleteSessionFromServer(id) {
return api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
/** Debounced best-effort push of local changes to the server. */
_syncHistoryToServer() {
if (this._syncTimer) clearTimeout(this._syncTimer);
this._syncTimer = setTimeout(() => {
this._syncTimer = null;
this._flushHistoryToServer();
}, 600);
}
async _flushHistoryToServer() {
if (this._syncInFlight) return;
this._syncInFlight = true;
try {
for (const id of [...this._deletedIds]) {
try {
await this._deleteSessionFromServer(id);
this._deletedIds.delete(id);
} catch { /* offline: retry next time */ }
}
for (const session of this._sessions) {
if (!this._dirtySessionIds.has(session.id)) continue;
try {
await this._upsertSessionToServer(session);
this._dirtySessionIds.delete(session.id);
} catch { /* offline: keep dirty, retry next time */ }
}
} finally {
this._syncInFlight = false;
this._notifyHistoryChanged();
}
}
_persistLocalCache() {
try {
localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions));
} catch { /* quota / private mode */ }
}
/** Local cache + schedule the server sync (#95). */
_persistSessions() {
this._persistLocalCache();
this._syncHistoryToServer();
this._notifyHistoryChanged();
}
/** Broad(cast) notification consumed by the sidebar history tab (#96). */
_notifyHistoryChanged() {
try {
window.dispatchEvent(new CustomEvent('bookslm:history-updated'));
} catch { /* ignore */ }
}
async _loadHistory(preferredSessionId = null) {
// 1. Local cache first (fast, offline-safe).
this._sessions = this._readSessions();
for (const migrated of this._migrateLegacyHistory()) this._positionSession(migrated);
// 2. Merge the server list (authoritative metadata); sessions present
// locally but unknown to the server are marked dirty and re-uploaded.
try {
const remote = await this._fetchHistorySummaryFromServer();
if (remote.length) {
const remoteIds = new Set();
const remoteBy = new Map();
for (const r of remote) {
remoteIds.add(r.id);
remoteBy.set(r.id, r);
}
for (const s of this._sessions) {
if (!remoteIds.has(s.id)) this._dirtySessionIds.add(s.id);
}
for (const r of remote) {
if (remoteBy.has(r.id) && this._sessions.some((s) => s.id === r.id)) continue;
this._positionSession(this._normalizeSession({ ...r, messages: null }));
}
this._persistLocalCache();
}
} catch { /* offline: keep the localStorage cache */ }
if (this._pendingNewSession) {
// A "new conversation" was requested before the panel existed
// (command palette): start a fresh session on top of the existing ones.
this._pendingNewSession = false;
const created = this._createSession([]);
this._positionSession(created);
this._dirtySessionIds.add(created.id);
this._persistSessions();
} else if (!this._sessions.length) {
const created = this._createSession([]);
this._positionSession(created);
this._dirtySessionIds.add(created.id);
this._persistSessions();
}
// 3. Pick the session to display: the requested one, else the latest of
// the current context, else any conversation.
let current = preferredSessionId
? this._sessions.find((s) => s.id === preferredSessionId) || null
: this._sessions.find((s) => s.context === this._contextKey()) || this._sessions[0] || null;
if (!current) {
current = this._createSession([]);
this._positionSession(current);
} else if (!Array.isArray(current.messages)) {
const full = await this._fetchSessionFromServer(current.id);
current.messages = full && Array.isArray(full.messages) ? full.messages : [];
this._persistLocalCache();
}
this._currentSessionId = current.id;
this._messages = current.messages || [];
this._notifyHistoryChanged();
}
_saveHistory() {
const persisted = this._persistableMessages(this._messages);
const session = this._currentSession();
if (session) {
session.messages = persisted;
session.updatedAt = Date.now();
if (!session.title) session.title = this._deriveTitle(this._messages);
this._dirtySessionIds.add(session.id);
this._positionSession(session);
} else {
const created = this._createSession(persisted);
created.title = this._deriveTitle(this._messages);
this._positionSession(created);
this._currentSessionId = created.id;
this._dirtySessionIds.add(created.id);
}
this._persistSessions();
}
_deriveTitle(messages) {
const firstUser = (messages || []).find((m) => m && m.role === 'user' && m.content && m.content.trim());
if (!firstUser) return '';
const line = firstUser.content.trim().split('\n')[0];
return line.length > 60 ? line.slice(0, 57) + '…' : line;
}
/** Select a session, hydrating it from the server when only a summary. */
async _selectSessionById(id) {
let session = this._sessions.find((s) => s.id === id) || null;
if (!session || !Array.isArray(session.messages)) {
const fetched = await this._fetchSessionFromServer(id);
if (fetched && Array.isArray(fetched.messages)) {
session = this._normalizeSession(fetched);
this._positionSession(session);
} else if (session) {
session.messages = session.messages || [];
}
}
if (!session) return;
this._currentSessionId = session.id;
this._messages = Array.isArray(session.messages) ? session.messages : [];
this._closeHistoryMenu();
if (this._panel) {
this._updateHeader();
this._renderMessages({ anchor: true });
this._showSuggestions();
this._renderHistoryMenu();
}
this._persistLocalCache();
}
async _loadSession(id) {
await this._selectSessionById(id);
}
_deleteSession(id) {
this._sessions = this._sessions.filter((s) => s.id !== id);
this._dirtySessionIds.delete(id);
this._deletedIds.add(id);
let session;
if (!this._sessions.length) {
session = this._createSession([]);
this._sessions.push(session);
this._dirtySessionIds.add(session.id);
} else if (id === this._currentSessionId) {
session = this._sessions[0];
} else {
session = this._currentSession() || this._sessions[0];
}
this._currentSessionId = session.id;
this._messages = session.messages || [];
this._persistSessions();
if (this._panel) {
this._renderMessages();
this._showSuggestions();
this._renderHistoryMenu();
}
}
_toggleHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (!menu) return;
const nowHidden = menu.classList.toggle('hidden');
if (!nowHidden) this._renderHistoryMenu();
}
_closeHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (menu) menu.classList.add('hidden');
}
_renderHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (!menu) return;
menu.innerHTML = '';
const sessions = [...this._sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
if (!sessions.length) {
const empty = document.createElement('div');
empty.className = 'bookslm-history-empty';
empty.textContent = t('bookslm.history_empty');
menu.appendChild(empty);
return;
}
for (const session of sessions) {
const item = document.createElement('div');
item.className = 'bookslm-history-item';
if (session.id === this._currentSessionId) item.classList.add('active');
const open = document.createElement('button');
open.type = 'button';
open.className = 'bookslm-history-open';
const title = document.createElement('span');
title.className = 'bookslm-history-title';
title.textContent = session.title || t('bookslm.untitled');
const meta = document.createElement('span');
meta.className = 'bookslm-history-meta';
const label = this._historyContextLabel(session);
const date = this._formatDate(session.updatedAt);
meta.textContent = label ? `${label} · ${date}` : date;
open.appendChild(title);
open.appendChild(meta);
open.addEventListener('click', () => this._loadSession(session.id));
const del = document.createElement('button');
del.type = 'button';
del.className = 'bookslm-history-delete';
del.title = t('bookslm.session_delete');
del.setAttribute('aria-label', t('bookslm.session_delete'));
del.innerHTML = '<i data-lucide="trash-2" style="width:14px;height:14px"></i>';
del.addEventListener('click', (e) => {
e.stopPropagation();
this._deleteSession(session.id);
});
item.appendChild(open);
item.appendChild(del);
menu.appendChild(item);
}
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_formatDate(ts) {
if (!ts) return '';
try {
return new Date(ts).toLocaleString();
} catch {
return '';
}
}
/** Short context reachable from a session, used by the history menu. */
_historyContextLabel(session) {
if (!session) return '';
const mode = session.mode || MODE.GENERAL;
if (mode === MODE.DIRECTORY) {
if (session.directory) return `📁 ${session.directory}`;
if (session.vault) return `📁 ${session.vault}`;
return t('bookslm.mode_directory');
}
if (mode === MODE.DOCUMENTS) return t('bookslm.mode_documents');
return t('bookslm.mode_general');
}
}
const booksLM = new BooksLM();
export default booksLM;
export { BooksLM };