Files
ObsiGate/frontend/js/bookslm.js
T
bruno 4c4e415975
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
2026-09-11 17:06:40 -04:00

1537 lines
54 KiB
JavaScript

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