Files
ObsiGate/frontend/js/bookslm.js
T
bruno eea2108ac2
CI / lint (push) Successful in 1m8s
CI / security (push) Successful in 44s
CI / test (push) Successful in 2m5s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m37s
fix(ai): lisibilite des modeles + contexte @ en mode General (BUG-010, BUG-011)
BUG-011: popover des modeles aligne a droite (right:0), largeur 340px bornee, noms sur plusieurs lignes + title. BUG-010: la selection @ capture le vault renvoye par tree-search et _contextVault() le propage aux requetes /context et /chat, corrigeant le contexte ad-hoc en mode General.
2026-09-12 13:50:14 -04:00

2246 lines
80 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';
export const MODE = Object.freeze({
DIRECTORY: 'directory',
DOCUMENTS: 'documents',
GENERAL: 'general',
});
// File extensions recognised when turning a mention into a clickable link.
// Anything else that looks like a path is treated as a directory (revealed
// in the sidebar tree instead of being opened in the viewer).
const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i;
// A path-like token: optional `dir/` segments followed by a final segment.
const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g;
// Image files that can be attached to a vision-capable model.
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i;
// Resizable panel bounds (px).
const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
/**
* Accent- and case-insensitive normalization used to match `/` commands and
* `@` mentions: skill ids/labels and vault paths may contain accented
* characters (é, à, ç…), which the composer regexes and filters must accept.
*/
function normalizeSearch(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Gather the files currently open across the singleton TabManager and every
* split-view pane (window globals avoid a circular import with ui.js).
* The active tab of each pane is listed first.
*/
export function collectOpenDocuments() {
const docs = [];
const seen = new Set();
const add = (vault, path) => {
if (!vault || !path) return;
const key = `${vault}\u0000${path}`;
if (seen.has(key)) return;
seen.add(key);
docs.push({ vault, path });
};
const scan = (tm) => {
if (!tm || !Array.isArray(tm._tabs)) return;
const active = tm._tabs.find((x) => x.id === tm._activeTabId);
if (active) add(active.vault, active.path);
for (const tab of tm._tabs) add(tab.vault, tab.path);
};
try {
scan(window.TabManager);
const pm = window.PaneManager;
if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan);
} catch { /* not ready yet */ }
return docs;
}
export function documentsSignature(documents) {
return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000');
}
class BooksLM {
constructor() {
this._isOpen = false;
this._mode = MODE.GENERAL;
this._vault = null;
this._directory = null;
this._documents = [];
this._effectiveScope = MODE.GENERAL;
this._messages = [];
this._contextFiles = [];
this._isLoading = false;
this._abortCtrl = null;
this._panel = null;
this._isFullscreen = false;
this._sessions = [];
this._currentSessionId = null;
this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode();
// Ad-hoc context added with `@` (files/directories) and images attached
// by paste or by mentioning an image file.
this._adhocFiles = [];
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
this._skills = [];
this._menuItems = [];
this._menuIndex = 0;
this._menuKind = null;
// Monotonic token used to drop stale async menu renders (fast typing).
this._menuSeq = 0;
}
_readAgentMode() {
try {
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
} catch {
return false;
}
}
_toggleAgentMode() {
this._agentMode = !this._agentMode;
try {
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
} catch { /* private mode */ }
this._updateAgentToggle();
}
_updateAgentToggle() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-agent');
if (!btn) return;
btn.classList.toggle('active', this._agentMode);
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
btn.title = label;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
}
// ── Public API ──────────────────────────────────────────────────────
/**
* Open the assistant for a specific context.
* @param {object} opts
* @param {'directory'|'documents'|'general'} opts.mode
* @param {string|null} [opts.vault]
* @param {string} [opts.directory]
* @param {Array<{vault:string,path:string}>} [opts.documents]
*/
async openContext(opts = {}) {
const mode = opts.mode || MODE.GENERAL;
const vault = opts.vault || null;
const directory = opts.directory || '';
const documents = Array.isArray(opts.documents) ? opts.documents : [];
const sameContext =
this._isOpen &&
this._mode === mode &&
this._vault === vault &&
this._directory === directory &&
documentsSignature(this._documents) === documentsSignature(documents);
if (sameContext) {
// Already open for this context: reveal a collapsed panel, else close.
if (this._panel && this._panel.classList.contains('hidden')) {
this._setHidden(false);
document.dispatchEvent(new CustomEvent('bookslm:opened'));
} else {
this.close();
}
return;
}
this._mode = mode;
this._vault = vault;
this._directory = directory;
this._documents = documents;
this._effectiveScope = mode;
this._messages = [];
this._contextFiles = [];
this._adhocFiles = [];
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
this._isLoading = true;
this._loadHistory();
if (!this._panel) {
this._panel = this._render();
document.body.appendChild(this._panel);
this._applyPanelWidth();
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// An explicit open always reveals the panel, even if it was collapsed
// in a previous session (previously it stayed hidden → "ne s'ouvre plus").
this._setHidden(false);
this._updateHeader();
this._updateStatus();
this._renderMessages();
this._showSuggestions();
this._renderAttachments();
this._closeHistoryMenu();
this._renderHistoryMenu();
requestAnimationFrame(() => {
this._panel.classList.add('open');
document.dispatchEvent(new CustomEvent('bookslm:opened', {
detail: { mode, vault, directory, documents },
}));
});
this._isOpen = true;
await this._loadContext();
this._isLoading = false;
}
/** Backwards-compatible helper: open the assistant on a directory. */
async open(vault, directory) {
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
}
/**
* Open the assistant using the current application context: the open
* document(s) if any, otherwise the General assistant.
*/
async openForCurrentContext() {
const docs = collectOpenDocuments();
if (docs.length) {
const vault = docs[0].vault;
const scoped = docs.filter((d) => d.vault === vault);
return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped });
}
return this.openContext({ mode: MODE.GENERAL });
}
close() {
if (!this._panel || !this._isOpen) return;
this._panel.classList.remove('open');
this._isOpen = false;
document.dispatchEvent(new CustomEvent('bookslm:closed'));
if (this._abortCtrl) {
this._abortCtrl.abort();
this._abortCtrl = null;
}
}
_setHidden(hidden) {
if (!this._panel) return;
this._panel.classList.toggle('hidden', hidden);
try {
localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false');
} catch { /* */ }
this._updateToggleIcon();
}
/** Collapse/expand while keeping the panel mounted (header toggle button). */
_toggleSidebar() {
if (!this._panel) return;
const isHidden = !this._panel.classList.contains('hidden');
this._setHidden(isHidden);
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
}
_updateToggleIcon() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-toggle');
if (!btn) return;
const isHidden = this._panel.classList.contains('hidden');
const icon = btn.querySelector('i[data-lucide]');
if (!icon) return;
icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
newConversation() {
// Called before the panel exists (command palette): defer the fresh
// session until the assistant opens for its actual context.
if (!this._panel) {
this._pendingNewSession = true;
this._messages = [];
return;
}
const session = this._createSession([]);
this._sessions.unshift(session);
this._currentSessionId = session.id;
this._messages = session.messages;
this._persistSessions();
this._renderMessages();
this._showSuggestions();
this._renderHistoryMenu();
}
exportConversation() {
if (!this._messages.length) return;
const current = this._currentSession();
const label = (current && current.title) || this._contextTitle() || 'assistant';
let md = `# ${label}\n\n`;
for (const msg of this._messages) {
const role = msg.role === 'user' ? '**You**' : '**AI**';
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
}
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(a.href);
}
openFile(path) {
return this._openFileLink(path);
}
/** Resolve the vault used to open/reveal a link mentioned by the assistant. */
_resolveVault() {
if (this._vault) return this._vault;
try {
const docs = collectOpenDocuments();
if (docs.length) return docs[0].vault;
} catch { /* not ready */ }
return state.currentVault || null;
}
/**
* Vault used for context/chat requests. Falls back to the vault of the first
* ad-hoc `@` entry: in General mode there is no base vault, but the selected
* files/directories still belong to a vault that must be sent to the server.
*/
_contextVault() {
if (this._vault) return this._vault;
const entry = this._adhocFiles[0] || this._adhocDirs[0] || this._images[0];
if (entry && entry.vault) return entry.vault;
return this._resolveVault();
}
/** Open a file mentioned in an assistant answer in the main viewer. */
async _openFileLink(path) {
const vault = this._resolveVault();
if (!vault || !path) {
showToast(t('bookslm.no_vault'), 'error');
return;
}
window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault, path } }));
try {
const m = await import('./viewer.js');
if (m && typeof m.openFile === 'function') await m.openFile(vault, path);
} catch (e) {
console.warn('AI assistant: open file link failed', e);
}
// Reposition the tree indicator on the opened file (best-effort).
try {
const s = await import('./sidebar.js');
if (s && typeof s.focusPathInSidebar === 'function') {
await s.focusPathInSidebar(vault, path, { alignToTop: true });
}
} catch (e) {
console.warn('AI assistant: reveal file in tree failed', e);
}
}
/** Reveal a directory (or file) path in the sidebar tree. */
async _revealPath(path) {
const vault = this._resolveVault();
if (!vault || !path) {
showToast(t('bookslm.no_vault'), 'error');
return;
}
window.dispatchEvent(new CustomEvent('obsigate:reveal-in-tree', { detail: { vault, path } }));
try {
const m = await import('./sidebar.js');
if (m && typeof m.focusPathInSidebar === 'function') {
await m.focusPathInSidebar(vault, path, { alignToTop: true, expandTarget: true });
}
} catch (e) {
console.warn('AI assistant: reveal path failed', e);
}
}
// ── Context ─────────────────────────────────────────────────────────
async _loadContext() {
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'),
subtitle: t('ai.context_general_desc'),
};
}
_contextTitle() {
return this._contextMeta().title;
}
// ── 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>
<div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<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-toolbar-label">${t('ai.model_settings')}</span>
<span class="bookslm-picker-host"></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-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
</div>
<div class="bookslm-input-hint">${t('ai.input_hint')}</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;
pickerHost.replaceWith(picker);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}).catch(() => { /* ignore */ });
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation();
this._toggleHistoryMenu();
});
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
// Clicking a file/path link inside an assistant answer either opens the
// file in the viewer or reveals the path in the sidebar tree.
panel.querySelector('.bookslm-messages').addEventListener('click', (e) => {
const link = e.target.closest('a.bookslm-path-link');
if (!link) return;
e.preventDefault();
const path = link.getAttribute('data-path');
if (!path) return;
if (link.getAttribute('data-kind') === 'dir') this._revealPath(path);
else this._openFileLink(path);
});
// Close the session / command menus when clicking elsewhere in the panel.
panel.addEventListener('click', (e) => {
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
this._closeHistoryMenu();
}
if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) {
this._hideMenus();
}
});
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
this._isFullscreen = !this._isFullscreen;
panel.classList.toggle('fullscreen', this._isFullscreen);
const icon = panel.querySelector('.bookslm-btn-fullscreen i');
if (icon) {
icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
// Resizable panel (drag the left edge).
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
// Image attachment button (pick image files from disk).
const attachBtn = panel.querySelector('.bookslm-btn-attach');
const fileInput = panel.querySelector('.bookslm-file-input');
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
this._addImageFiles(fileInput.files);
fileInput.value = '';
});
}
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
this._onComposerInput();
});
textarea.addEventListener('paste', (e) => this._onPaste(e));
textarea.addEventListener('keydown', (e) => {
// Menu navigation (mention/command) takes precedence over submit.
if (this._handleMenuKey(e)) return;
if (e.key !== 'Enter') return;
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+Enter → insert a newline at the caret.
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
textarea.dispatchEvent(new Event('input'));
return;
}
// Enter (with or without Shift) → submit. Shift+Enter also submits so
// the documented shortcut (Ctrl+Enter) is the only way to add a line.
e.preventDefault();
this._sendMessage();
});
return panel;
}
// ── Panel resize ────────────────────────────────────────────────────
_applyPanelWidth() {
if (!this._panel) return;
let width = 0;
try { width = parseInt(localStorage.getItem(PANEL_WIDTH_KEY) || '', 10) || 0; } catch { /* */ }
if (width) this._panel.style.width = `${this._clampPanelWidth(width)}px`;
}
_clampPanelWidth(width) {
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
}
_startResize(e) {
if (!this._panel || this._isFullscreen) return;
e.preventDefault();
const startX = e.clientX;
const startWidth = this._panel.getBoundingClientRect().width;
const onMove = (ev) => {
// Panel is anchored to the right edge: dragging left widens it.
const next = this._clampPanelWidth(startWidth + (startX - ev.clientX));
this._panel.style.width = `${next}px`;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('bookslm-resizing');
try {
localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(this._panel.getBoundingClientRect().width)));
} catch { /* */ }
};
document.body.classList.add('bookslm-resizing');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ── Attachments: ad-hoc context chips & images ──────────────────────
_renderAttachments() {
if (!this._panel) return;
const host = this._panel.querySelector('.bookslm-attachments');
if (!host) return;
host.innerHTML = '';
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
host.classList.toggle('empty', !hasAny);
if (!hasAny) return;
if (this._activeSkill) {
const skill = this._skillById(this._activeSkill);
host.appendChild(this._chip(
`${skill ? skill.icon : '✨'} ${skill ? skill.label : this._activeSkill}`,
'skill',
() => { this._activeSkill = null; this._renderAttachments(); },
));
}
for (const file of this._adhocFiles) {
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
}
for (const dir of this._adhocDirs) {
host.appendChild(this._chip(`📁 ${dir.path}`, 'dir', () => this._removeAdhocDir(dir.path)));
}
this._images.forEach((img, index) => {
const chip = this._chip(`🖼 ${img.name || t('ai.image')}`, 'image', () => {
this._images.splice(index, 1);
this._renderAttachments();
});
if (img.previewUrl) {
const thumb = document.createElement('img');
thumb.className = 'bookslm-chip-thumb';
thumb.src = img.previewUrl;
chip.insertBefore(thumb, chip.firstChild);
}
host.appendChild(chip);
});
}
_chip(label, kind, onRemove) {
const chip = document.createElement('span');
chip.className = `bookslm-chip bookslm-chip-${kind}`;
const text = document.createElement('span');
text.className = 'bookslm-chip-label';
text.textContent = label;
text.title = label;
chip.appendChild(text);
const close = document.createElement('button');
close.type = 'button';
close.className = 'bookslm-chip-remove';
close.setAttribute('aria-label', t('ai.remove'));
close.textContent = '×';
close.addEventListener('click', onRemove);
chip.appendChild(close);
return chip;
}
_addAdhocFile(path, vault) {
if (!path || this._adhocFiles.some((f) => f.path === path)) return;
this._adhocFiles.push({ path, vault: vault || null });
this._renderAttachments();
this._loadContext();
}
_addAdhocDir(path, vault) {
const clean = path.replace(/\/+$/, '');
if (!clean || this._adhocDirs.some((d) => d.path === clean)) return;
this._adhocDirs.push({ path: clean, vault: vault || null });
this._renderAttachments();
this._loadContext();
}
_removeAdhocFile(path) {
this._adhocFiles = this._adhocFiles.filter((f) => f.path !== path);
this._renderAttachments();
this._loadContext();
}
_removeAdhocDir(path) {
this._adhocDirs = this._adhocDirs.filter((d) => d.path !== path);
this._renderAttachments();
this._loadContext();
}
_addImageFile(file) {
if (!file || !file.type || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const match = result.match(/^data:([^;,]+);base64,(.*)$/);
if (!match) return;
this._images.push({
name: file.name || t('ai.image'),
mime_type: match[1],
data: match[2],
previewUrl: result,
});
this._renderAttachments();
};
reader.readAsDataURL(file);
}
_addImageFiles(files) {
if (!files) return;
Array.from(files).forEach((file) => this._addImageFile(file));
}
_onPaste(e) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const images = [];
for (const item of items) {
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) images.push(file);
}
}
if (images.length) {
e.preventDefault();
images.forEach((file) => this._addImageFile(file));
}
}
// ── Composer: `/` commands and `@` mentions ─────────────────────────
_onComposerInput() {
if (!this._panel) return;
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
const pos = textarea.selectionStart;
const before = textarea.value.slice(0, pos);
// Unicode-aware so accented skill names / paths keep the menu open.
const command = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]*)$/u);
if (command) {
this._showCommandMenu(command[1]);
return;
}
const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u);
if (mention) {
this._showMentionMenu(mention[1]);
return;
}
this._hideMenus();
}
async _ensureSkills() {
if (this._skills.length) return this._skills;
try {
const data = await api('/api/ai/skills');
this._skills = [...(data.skills || []), ...(data.commands || [])];
} catch (e) {
console.warn('AI assistant: skills load failed', e);
this._skills = [];
}
return this._skills;
}
_skillById(id) {
return this._skills.find((s) => s.id === id) || null;
}
async _showCommandMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const skills = await this._ensureSkills();
if (seq !== this._menuSeq) return; // a newer input superseded this render
const q = normalizeSearch(query);
const matches = skills.filter((s) =>
!q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q));
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
}
_selectCommand(skill) {
const textarea = this._panel && this._panel.querySelector('textarea');
this._hideMenus();
if (skill.type === 'admin') {
this._runAdminCommand(skill.id);
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
}
return;
}
if (skill.special === 'create_skill') {
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
this._promptCreateSkill();
return;
}
this._activeSkill = skill.id;
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
textarea.focus();
}
this._renderAttachments();
}
async _showMentionMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const vault = this._contextVault();
if (!vault && !query) {
// Nothing to list without a vault; a typed query can still search all.
this._hideMenus();
return;
}
try {
let results = [];
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 {
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' }));
}
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 || null };
});
this._renderMenu('mention', items, (item) => this._selectMention(item));
} catch (e) {
if (seq !== this._menuSeq) return;
console.warn('AI assistant: mention search failed', e);
this._hideMenus();
}
}
_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._resolveVault();
if (IMAGE_EXT_RE.test(path)) {
this._addImagePath(path, vault);
} else if (item.type === 'dir' || this._classifyPath(path) === 'dir') {
this._addAdhocDir(path, vault);
} else {
this._addAdhocFile(path, vault);
}
}
_addImagePath(path, vault) {
if (!path || this._images.some((img) => img.path === path)) return;
this._images.push({ path, name: path.split('/').pop(), vault: vault || null });
this._renderAttachments();
}
_renderMenu(kind, items, onPick) {
if (!this._panel) return;
const menu = this._panel.querySelector(`.bookslm-${kind}-menu`);
if (!menu) return;
menu.innerHTML = '';
this._menuKind = kind;
this._menuItems = items;
this._menuIndex = 0;
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'bookslm-menu-empty';
empty.textContent = t('ai.no_results');
menu.appendChild(empty);
menu.classList.remove('hidden');
return;
}
items.forEach((item, index) => {
const el = document.createElement('button');
el.type = 'button';
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
el.dataset.index = String(index);
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
const icon = document.createElement('span');
icon.className = 'bookslm-menu-icon';
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
const label = document.createElement('span');
label.className = 'bookslm-menu-label';
label.textContent = item.label || item.id;
el.appendChild(icon);
el.appendChild(label);
if (item.usage) {
const usage = document.createElement('span');
usage.className = 'bookslm-menu-usage';
usage.textContent = item.usage;
el.appendChild(usage);
} else if (item.description) {
const desc = document.createElement('span');
desc.className = 'bookslm-menu-desc';
desc.textContent = item.description;
el.appendChild(desc);
}
el.addEventListener('mouseenter', () => this._highlightMenu(index));
el.addEventListener('click', (ev) => {
ev.stopPropagation();
onPick(item);
});
menu.appendChild(el);
});
// Show the other menu hidden.
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
if (m !== menu) m.classList.add('hidden');
});
menu.classList.remove('hidden');
}
_highlightMenu(index) {
if (!this._panel || !this._menuKind) return;
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
if (!menu) return;
const options = menu.querySelectorAll('.bookslm-menu-item');
if (!options.length) return;
const next = ((index % options.length) + options.length) % options.length;
this._menuIndex = next;
options.forEach((el, i) => {
const on = i === next;
el.classList.toggle('active', on);
el.setAttribute('aria-selected', on ? 'true' : 'false');
});
const active = options[next];
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' });
}
}
_handleMenuKey(e) {
if (!this._panel || !this._menuKind) return false;
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
if (!menu || menu.classList.contains('hidden')) return false;
const items = this._menuItems;
if (e.key === 'Escape') {
e.preventDefault();
this._hideMenus();
return true;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!items.length) return true;
const delta = e.key === 'ArrowDown' ? 1 : -1;
const next = (this._menuIndex + delta + items.length) % items.length;
this._highlightMenu(next);
return true;
}
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const item = items[this._menuIndex];
if (!item) return true;
const menuEl = menu.querySelector('.bookslm-menu-item.active');
if (menuEl) menuEl.click();
return true;
}
return false;
}
_hideMenus() {
if (!this._panel) return;
// Invalidate any in-flight async menu render.
this._menuSeq++;
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
m.classList.add('hidden');
});
this._menuKind = null;
this._menuItems = [];
this._menuIndex = 0;
}
// ── Admin commands (`/help`, `/providers`, …) ───────────────────────
async _runAdminCommand(id) {
switch (id) {
case 'help': return this._cmdHelp();
case 'providers': return this._cmdProviders();
case 'keys': return this._cmdKeys();
case 'provider': return this._cmdSwitchProvider();
case 'model': return this._cmdSwitchModel();
default: return undefined;
}
}
_pushAssistantNote(markdown) {
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
this._renderMessages();
this._saveHistory();
}
async _cmdHelp() {
const skills = await this._ensureSkills();
const lines = [`## ${t('ai.commands')}`, ''];
for (const s of skills) {
lines.push(`- ${s.icon || ''} \`/${s.id}\` — ${s.description || s.label}`);
}
this._pushAssistantNote(lines.join('\n'));
}
async _cmdProviders() {
let providers = {};
try {
const status = await api('/api/ai/status');
providers = status.providers || {};
} catch { /* */ }
const lines = [`## ${t('ai.providers')}`, ''];
const names = Object.keys(providers);
if (!names.length) lines.push(t('ai.none'));
for (const name of names) {
const info = providers[name] || {};
lines.push(`- ${info.available ? '✅' : '❌'} **${name}** — ${info.model || '—'}`);
}
this._pushAssistantNote(lines.join('\n'));
}
async _cmdKeys() {
const lines = [`## ${t('ai.keys')}`, ''];
try {
const keys = await api('/api/config/ai-keys');
for (const [env, value] of Object.entries(keys)) {
lines.push(`- ${value ? '✅' : '❌'} **${env.replace('_API_KEY', '').toLowerCase()}**`);
}
} catch {
const status = await api('/api/ai/status').catch(() => ({ providers: {} }));
for (const [name, info] of Object.entries(status.providers || {})) {
lines.push(`- ${info.available ? '✅' : '❌'} **${name}**`);
}
}
this._pushAssistantNote(lines.join('\n'));
}
_cmdSwitchProvider() {
const names = Array.from(this._panel.querySelectorAll('.ai-picker-select option'))
.map((o) => o.value).filter(Boolean);
const list = names.join(', ');
this._pushAssistantNote(
`### ${t('ai.provider')}\n\n${t('ai.usage_provider', { list })}\n\n\`/provider <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 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 (icon) icon.setAttribute('data-lucide', meta.icon);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_updateStatus(data) {
if (!this._panel) return;
const status = this._panel.querySelector('.bookslm-status');
if (!status) return;
if (data && data.error) {
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
return;
}
if (data && Array.isArray(data.files)) {
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 = data.scope === MODE.GENERAL
? t('ai.context_general_desc')
: `${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.textContent = '⏳ …';
}
}
_suggestionsForContext() {
if (this._mode === MODE.GENERAL) {
return [
t('ai.suggestion_capabilities'),
t('ai.suggestion_search'),
t('ai.suggestion_create_file'),
];
}
if (this._mode === MODE.DOCUMENTS) {
return [
t('ai.suggestion_docs_summary'),
t('ai.suggestion_docs_keypoints'),
t('ai.suggestion_docs_contradictions'),
];
}
return [
t('bookslm.suggestion_summary'),
t('bookslm.suggestion_themes'),
t('bookslm.suggestion_contradictions'),
];
}
_showSuggestions() {
if (!this._panel) return;
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (!sugEl) return;
sugEl.innerHTML = '';
if (this._messages.length) return;
for (const s of this._suggestionsForContext()) {
const btn = document.createElement('button');
btn.className = 'bookslm-suggestion';
btn.textContent = s;
btn.addEventListener('click', () => {
const textarea = this._panel.querySelector('textarea');
if (textarea) {
textarea.value = s;
this._sendMessage();
}
});
sugEl.appendChild(btn);
}
}
_renderMessages() {
if (!this._panel) return;
const container = this._panel.querySelector('.bookslm-messages');
if (!container) return;
container.innerHTML = '';
for (const msg of this._messages) {
const bubble = document.createElement('div');
bubble.className = `bookslm-bubble ${msg.role}`;
if (msg.role === 'assistant') {
const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text);
if (msg.toolCalls && msg.toolCalls.length) {
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
}
if (actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions';
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
bubble.appendChild(actionsDiv);
}
if (msg.confirmation) {
bubble.appendChild(this._renderConfirmationCard(msg));
}
if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources';
for (const src of msg.sources) {
const badge = document.createElement('span');
badge.className = 'bookslm-source-badge';
badge.textContent = `📄 ${src.split('/').pop()}`;
badge.title = src;
badge.addEventListener('click', () => this.openFile(src));
sourcesDiv.appendChild(badge);
}
bubble.appendChild(sourcesDiv);
}
} else {
bubble.textContent = msg.content;
}
container.appendChild(bubble);
}
container.scrollTop = container.scrollHeight;
}
// ── Actions (general assistant) ─────────────────────────────────────
_extractActions(text) {
const actions = [];
const re = /```obsigate-action\s*\n([\s\S]*?)```/g;
let match;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (obj && typeof obj.action === 'string') actions.push(obj);
} catch { /* malformed block — ignore */ }
}
return { text: text.replace(re, '').trim(), actions };
}
_renderActionCard(action) {
const card = document.createElement('div');
card.className = 'bookslm-action';
const isDir = action.action === 'create_directory';
const label = isDir
? t('ai.action_create_dir', { path: action.path || '' })
: t('ai.action_create_file', { path: action.path || '' });
const meta = document.createElement('div');
meta.className = 'bookslm-action-meta';
meta.innerHTML = `<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) ──────────────────────
_renderToolActivity(toolCalls) {
const wrap = document.createElement('div');
wrap.className = 'bookslm-tool-trace';
for (const call of toolCalls) {
const line = document.createElement('div');
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
line.textContent = `${call.ok === false ? '⚠' : '🔧'} ${t('ai.tool_call', { name: call.name })}`;
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
wrap.appendChild(line);
}
return wrap;
}
_renderConfirmationCard(msg) {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
const meta = document.createElement('div');
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
}
card.appendChild(diffHost);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
try {
await this._applyConfirmation(msg);
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
card.appendChild(apply);
return card;
}
async _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
let current = null;
try {
current = await this._fetchCurrentContent(args.vault, args.path);
} catch {
current = null;
}
const title = `<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;
}
async _applyConfirmation(msg) {
const conf = msg.confirmation;
if (!conf || conf._applying) return;
conf._applying = true;
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await this._postChat(payload);
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
// The confirmation is resolved: drop the card and show the continuation.
msg.confirmation = null;
this._messages.push(continuation);
await this._streamResponse(resp, continuation, payload);
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._renderMessages();
this._saveHistory();
}
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
const textarea = this._panel.querySelector('textarea');
const text = (textarea.value || '').trim();
if (!text || this._isLoading) return;
// Admin slash-commands are handled locally (no LLM round-trip).
if (/^\/(?:help|providers|keys|provider|model)(?:\s|$)/i.test(text)) {
this._hideMenus();
textarea.value = '';
textarea.style.height = 'auto';
if (!this._maybeRunAdminWithArg(text)) {
this._runAdminCommand(text.slice(1).split(/\s+/)[0].toLowerCase());
}
return;
}
// Vision gate: block sending images to a non-vision model.
if (this._images.length) {
const visionOk = await this._modelSupportsVision();
if (!visionOk) {
showToast(t('ai.vision_unsupported'), 'error');
return;
}
}
textarea.value = '';
textarea.style.height = 'auto';
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Snapshot prior turns BEFORE appending the new user message.
const history = this._messages
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content }));
this._messages.push({ role: 'user', content: text });
this._renderMessages();
let provider = null;
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} catch { /* */ }
const payload = {
mode: this._mode,
vault: this._contextVault(),
directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
extra_files: this._adhocFiles.map((f) => f.path),
extra_directories: this._adhocDirs.map((d) => d.path),
images: this._images.map((img) => (
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
)),
skill: this._activeSkill,
message: text,
conversation_history: history,
provider,
model,
};
const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await this._postChat(payload);
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
await this._streamResponse(resp, assistantMsg, payload);
if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase();
assistantMsg.sources = this._contextFiles
.filter((f) => {
const name = (f.path || f || '').split('/').pop().toLowerCase();
return name && contentLower.includes(name);
})
.map((f) => f.path || f);
}
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
}
/** POST to /chat or /agent depending on the agent-mode toggle. */
_postChat(payload) {
// Images require the plain multimodal chat endpoint (the agent loop is
// text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = (this._agentMode && !hasImages)
? '/api/ai/bookslm/agent'
: '/api/ai/bookslm/chat';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl ? this._abortCtrl.signal : undefined,
});
}
/**
* Consume an SSE response, feeding tokens/tool/confirmation events into
* `assistantMsg`. Shared by the initial send and the confirmation resume.
*/
async _streamResponse(resp, assistantMsg, payload) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'tool') {
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
return;
}
if (currentEvent === 'confirmation') {
assistantMsg.confirmation = {
pending: data.pending || data,
messages: data.messages || [],
};
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
}
// ── Markdown (self-contained renderer) ──────────────────────────────
//
// Renders the assistant answer as formatted HTML (headings, lists, tables,
// blockquotes, code, emphasis) without any external dependency. All model
// text is HTML-escaped before markup is produced, and file/path mentions
// become clickable links handled by the delegated listener in `_render()`.
_renderMarkdown(text) {
if (!text) return '';
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
const out = [];
const para = [];
let i = 0;
const flushPara = () => {
if (!para.length) return;
out.push(`<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).
s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) =>
put(this._renderMdLink(label, target)));
// Bare `dir/file.ext` mentions.
s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) =>
`${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`);
// Emphasis.
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<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>`;
}
const raw = this._decodeEntities(target).replace(/^\.\//, '');
return this._pathLink(label, raw);
}
/** Build a clickable link for a vault-relative file or directory path. */
_pathLink(display, rawPath) {
const clean = String(rawPath || '').replace(/^\.\//, '');
const kind = this._classifyPath(clean);
const attr = this._escapeAttr(clean);
return `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
}
_classifyPath(path) {
return FILE_EXT_RE.test(path || '') ? 'file' : 'dir';
}
_looksLikePath(value) {
if (!value) return false;
const p = value.trim();
if (!p || /\s/.test(p)) return false;
if (!/^[\w./\\-]+$/.test(p)) return false;
return p.includes('/') || FILE_EXT_RE.test(p);
}
_escapeHtml(s) {
return String(s)
.replace(/&/g, '&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 ─────────────────────────────────────────────────
//
// Each context (directory / documents / general) owns a list of sessions
// persisted in localStorage. A session is `{id, title, createdAt,
// updatedAt, messages}`; the header history button lists them so a previous
// conversation can be reopened or deleted.
_contextKey() {
const ctx = this._mode === MODE.DOCUMENTS
? documentsSignature(this._documents)
: (this._directory || this._vault || 'general');
return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
}
_sessionsKey() {
return `bookslm-sessions-${this._contextKey()}`;
}
_legacyHistoryKey() {
return `bookslm-history-${this._contextKey()}`;
}
_newSessionId() {
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
_createSession(messages) {
const now = Date.now();
return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] };
}
_currentSession() {
return this._sessions.find((s) => s.id === this._currentSessionId) || null;
}
_readSessions() {
try {
const raw = localStorage.getItem(this._sessionsKey());
if (!raw) return [];
const list = JSON.parse(raw);
if (!Array.isArray(list)) return [];
return list
.filter((s) => s && typeof s === 'object' && Array.isArray(s.messages))
.map((s) => ({
id: s.id || this._newSessionId(),
title: s.title || '',
createdAt: s.createdAt || Date.now(),
updatedAt: s.updatedAt || s.createdAt || Date.now(),
messages: s.messages,
}));
} catch {
return [];
}
}
/** Import the pre-sessions single conversation, if any. */
_migrateLegacyHistory() {
try {
const raw = localStorage.getItem(this._legacyHistoryKey());
if (!raw) return null;
const messages = JSON.parse(raw);
if (!Array.isArray(messages) || !messages.length) return null;
const session = this._createSession(messages);
session.title = this._deriveTitle(messages);
return session;
} catch {
return null;
}
}
_loadHistory() {
this._sessions = this._readSessions();
if (!this._sessions.length) {
const migrated = this._migrateLegacyHistory();
if (migrated) this._sessions = [migrated];
}
if (this._pendingNewSession) {
// A "new conversation" was requested before the panel existed
// (command palette): start a fresh session on top of the existing ones.
this._pendingNewSession = false;
this._sessions.unshift(this._createSession([]));
this._persistSessions();
} else if (!this._sessions.length) {
this._sessions = [this._createSession([])];
}
this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
const current = this._sessions[0];
this._currentSessionId = current.id;
this._messages = Array.isArray(current.messages) ? current.messages : [];
}
_persistSessions() {
try {
localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions));
} catch { /* quota / private mode */ }
}
/** Strip transient (large) fields before persisting a conversation. */
_persistableMessages() {
return this._messages.map(({ confirmation, payload, ...rest }) => rest);
}
_saveHistory() {
const persisted = this._persistableMessages();
const session = this._currentSession();
if (session) {
session.messages = persisted;
session.updatedAt = Date.now();
if (!session.title) session.title = this._deriveTitle(this._messages);
} else {
const created = this._createSession(persisted);
created.title = this._deriveTitle(this._messages);
this._sessions.unshift(created);
this._currentSessionId = created.id;
}
this._persistSessions();
}
_deriveTitle(messages) {
const firstUser = (messages || []).find((m) => m && m.role === 'user' && m.content && m.content.trim());
if (!firstUser) return '';
const line = firstUser.content.trim().split('\n')[0];
return line.length > 60 ? line.slice(0, 57) + '…' : line;
}
_loadSession(id) {
const session = this._sessions.find((s) => s.id === id);
if (!session) return;
this._currentSessionId = id;
this._messages = Array.isArray(session.messages) ? session.messages : [];
this._closeHistoryMenu();
if (this._panel) {
this._renderMessages();
this._showSuggestions();
this._renderHistoryMenu();
}
}
_deleteSession(id) {
this._sessions = this._sessions.filter((s) => s.id !== id);
if (!this._sessions.length) {
const session = this._createSession([]);
this._sessions.push(session);
this._currentSessionId = session.id;
this._messages = session.messages;
} else if (id === this._currentSessionId) {
const next = this._sessions[0];
this._currentSessionId = next.id;
this._messages = next.messages || [];
}
this._persistSessions();
if (this._panel) {
this._renderMessages();
this._showSuggestions();
this._renderHistoryMenu();
}
}
_toggleHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (!menu) return;
const nowHidden = menu.classList.toggle('hidden');
if (!nowHidden) this._renderHistoryMenu();
}
_closeHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (menu) menu.classList.add('hidden');
}
_renderHistoryMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-history-menu');
if (!menu) return;
menu.innerHTML = '';
const sessions = [...this._sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
if (!sessions.length) {
const empty = document.createElement('div');
empty.className = 'bookslm-history-empty';
empty.textContent = t('bookslm.history_empty');
menu.appendChild(empty);
return;
}
for (const session of sessions) {
const item = document.createElement('div');
item.className = 'bookslm-history-item';
if (session.id === this._currentSessionId) item.classList.add('active');
const open = document.createElement('button');
open.type = 'button';
open.className = 'bookslm-history-open';
const title = document.createElement('span');
title.className = 'bookslm-history-title';
title.textContent = session.title || t('bookslm.untitled');
const date = document.createElement('span');
date.className = 'bookslm-history-date';
date.textContent = this._formatDate(session.updatedAt);
open.appendChild(title);
open.appendChild(date);
open.addEventListener('click', () => this._loadSession(session.id));
const del = document.createElement('button');
del.type = 'button';
del.className = 'bookslm-history-delete';
del.title = t('bookslm.session_delete');
del.setAttribute('aria-label', t('bookslm.session_delete'));
del.innerHTML = '<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 '';
}
}
}
const booksLM = new BooksLM();
export default booksLM;
export { BooksLM };