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
1537 lines
54 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
_escapeAttr(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
_decodeEntities(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 };
|