Files
ObsiGate/frontend/js/bookslm.js
T
bruno 524e6da591
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(bookslm): v2.2.0 - BooksLM chat AI contextuel par répertoire + 4 providers AI
BooksLM (#76):
- Panneau chat slide-in 450px, clic-droit répertoire → 🧠 BooksLM
- Collecte récursive .md avec limites (200 fichiers, 200K chars)
- Cache SHA-256, redaction secrets, priorité README/index
- SSE streaming, badges sources cliquables, historique localStorage
- Commandes palette: BooksLM ouvrir/nouvelle conversation

Providers AI (4 nouveaux):
- NVIDIA (integrate.api.nvidia.com)
- QwenCloud (dashscope.aliyuncs.com)
- Xiaomi (api.xiaomi.com)
- Mistral (api.mistral.ai)
- Tous OpenAI-compatible, auto-listing modèles

Fix: dropdown config-select suit maintenant le thème (option bg/color)
27 tests BooksLM + 466 tests au total
2026-09-06 19:42:40 -04:00

415 lines
13 KiB
JavaScript

// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
import { t } from './i18n.js';
class BooksLM {
constructor() {
this._isOpen = false;
this._vault = null;
this._directory = null;
this._messages = [];
this._contextFiles = [];
this._isLoading = false;
this._abortCtrl = null;
this._panel = null;
this._isFullscreen = false;
}
// ── Public API ──────────────────────────────────────────────────────
async open(vault, directory) {
if (this._isOpen && this._vault === vault && this._directory === directory) {
this.close();
return;
}
this._vault = vault;
this._directory = directory;
this._messages = [];
this._contextFiles = [];
this._isLoading = true;
// Restore history
this._loadHistory();
// Build panel if needed
if (!this._panel) {
this._panel = this._render();
document.body.appendChild(this._panel);
}
this._updateHeader();
this._updateStatus();
this._renderMessages();
// Open with animation
requestAnimationFrame(() => {
this._panel.classList.add('open');
});
this._isOpen = true;
// Load context
try {
const resp = await fetch('/api/ai/bookslm/context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vault, directory })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this._contextFiles = data.files || [];
this._updateStatus(data);
this._showSuggestions();
} catch (e) {
console.warn('BooksLM: context load failed', e);
this._contextFiles = [];
this._updateStatus({ error: e.message });
}
this._isLoading = false;
}
close() {
if (!this._panel || !this._isOpen) return;
this._panel.classList.remove('open');
this._isOpen = false;
// Abort any in-flight request
if (this._abortCtrl) {
this._abortCtrl.abort();
this._abortCtrl = null;
}
}
newConversation() {
this._messages = [];
this._saveHistory();
if (this._panel) {
this._renderMessages();
this._showSuggestions();
}
if (!this._isOpen && this._vault && this._directory) {
this.open(this._vault, this._directory);
}
}
exportConversation() {
if (!this._messages.length) return;
let md = `# BooksLM — ${this._directory}\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 = `bookslm-${this._directory.replace(/\//g, '_')}.md`;
a.click();
URL.revokeObjectURL(a.href);
}
openFile(path) {
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
detail: { vault: this._vault, path }
}));
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
const panel = document.createElement('div');
panel.className = 'bookslm-panel';
panel.innerHTML = `
<div class="bookslm-header">
<span>📚</span>
<span class="bookslm-title"></span>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
<button class="bookslm-btn-close" title="✕">✕</button>
</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"></textarea>
<button class="bookslm-btn-send">${t('bookslm.send')}</button>
</div>
`;
// Wire events
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
this._isFullscreen = !this._isFullscreen;
panel.classList.toggle('fullscreen', this._isFullscreen);
});
// Send button
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
// Textarea auto-grow + Ctrl+Enter
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
});
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this._sendMessage();
}
});
return panel;
}
_updateHeader() {
if (!this._panel) return;
const title = this._panel.querySelector('.bookslm-title');
if (title) {
title.textContent = this._directory
? `📚 ${this._directory.split('/').pop() || this._vault}`
: t('bookslm.title');
}
}
_updateStatus(data) {
if (!this._panel) return;
const status = this._panel.querySelector('.bookslm-status');
if (!status) return;
if (data && data.error) {
status.innerHTML = `<span style="color:#f87171">⚠ ${t('bookslm.no_context')}</span>`;
return;
}
if (data && data.files) {
const count = data.files.length;
const chars = data.total_chars || 0;
const pct = Math.min(100, Math.round((chars / 100000) * 100));
status.innerHTML = `
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
`;
} else if (this._isLoading) {
status.textContent = '⏳ ...';
}
}
_showSuggestions() {
if (!this._panel) return;
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (!sugEl) return;
sugEl.innerHTML = '';
if (!this._contextFiles.length && !this._isLoading) return;
const suggestions = [
t('bookslm.suggestion_summary'),
t('bookslm.suggestion_themes'),
t('bookslm.suggestion_contradictions')
];
for (const s of suggestions) {
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') {
bubble.innerHTML = this._renderMarkdown(msg.content || '');
// Source badges
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);
}
// Scroll to bottom
container.scrollTop = container.scrollHeight;
}
// ── 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';
// Hide suggestions
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Add user message
this._messages.push({ role: 'user', content: text });
this._renderMessages();
// Add assistant placeholder
const assistantMsg = { role: 'assistant', content: '', sources: [] };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
// Disable send button
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
const resp = await fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vault: this._vault,
directory: this._directory,
messages: this._messages.slice(0, -1), // exclude empty assistant
context_files: this._contextFiles.map(f => f.path || f)
}),
signal: this._abortCtrl.signal
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;
try {
const data = JSON.parse(payload);
if (data.content) {
assistantMsg.content += data.content;
}
if (data.sources) {
assistantMsg.sources = data.sources;
}
} catch {
// Raw text token
assistantMsg.content += payload;
}
this._renderMessages();
}
}
// Extract sources from context files if mentioned
if (!assistantMsg.sources.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('BooksLM chat error:', e);
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
}
// ── Markdown (lightweight) ──────────────────────────────────────────
_renderMarkdown(text) {
if (!text) return '';
let html = text
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
// Unordered lists
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
// Headers
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
}
// ── History persistence ─────────────────────────────────────────────
_historyKey() {
return `bookslm-history-${this._vault}-${this._directory}`;
}
_saveHistory() {
try {
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
} catch {}
}
_loadHistory() {
try {
const data = localStorage.getItem(this._historyKey());
if (data) {
this._messages = JSON.parse(data);
}
} catch {
this._messages = [];
}
}
}
const booksLM = new BooksLM();
export default booksLM;
export { BooksLM };