CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Contexte: - Repertoire (mode historique BooksLM, via menu contextuel dossier) - Documents (bouton flottant quand des documents sont ouverts) - General (bouton flottant sans document: aide app + creation de fichiers) - Le contexte est affiche dans le header; suggestions adaptees au mode - Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto) Backend: - /api/ai/bookslm/context et /chat acceptent mode + context_files - collect_files_context(), empty_context(), build_general_system_prompt() - mode documents degrade vers general si aucun fichier lisible Corrige: - bouton flottant passait le chemin d'un fichier comme repertoire -> erreur 'Aucun fichier markdown trouve dans ce dossier' - panneau precedent masque (localStorage) ne se rouvrait plus - selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles) - Entree envoie, Ctrl+Entree insere un saut de ligne Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
763 lines
27 KiB
JavaScript
763 lines
27 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';
|
|
|
|
export const MODE = Object.freeze({
|
|
DIRECTORY: 'directory',
|
|
DOCUMENTS: 'documents',
|
|
GENERAL: 'general',
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// ── 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();
|
|
|
|
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() {
|
|
this._messages = [];
|
|
this._saveHistory();
|
|
if (this._panel) {
|
|
this._renderMessages();
|
|
this._showSuggestions();
|
|
}
|
|
}
|
|
|
|
exportConversation() {
|
|
if (!this._messages.length) return;
|
|
const label = 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) {
|
|
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
|
|
detail: { vault: this._vault, path },
|
|
}));
|
|
}
|
|
|
|
// ── 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-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-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-export').addEventListener('click', () => this.exportConversation());
|
|
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
|
|
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 (actions.length) {
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'bookslm-actions';
|
|
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
|
|
bubble.appendChild(actionsDiv);
|
|
}
|
|
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 */ }
|
|
}
|
|
|
|
// ── 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();
|
|
|
|
const assistantMsg = { role: 'assistant', content: '', sources: [] };
|
|
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 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 doFetch = () => {
|
|
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
|
return fetch('/api/ai/bookslm/chat', {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload),
|
|
signal: this._abortCtrl.signal,
|
|
});
|
|
};
|
|
let resp = await doFetch();
|
|
if (resp.status === 401 && AuthManager._authEnabled) {
|
|
await AuthManager.refreshAccessToken();
|
|
resp = await doFetch();
|
|
}
|
|
if (!resp.ok) {
|
|
let detail = `HTTP ${resp.status}`;
|
|
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
|
|
throw new Error(detail);
|
|
}
|
|
|
|
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 === '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);
|
|
|
|
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();
|
|
}
|
|
|
|
// ── Markdown (lightweight) ──────────────────────────────────────────
|
|
|
|
_renderMarkdown(text) {
|
|
if (!text) return '';
|
|
// Escape HTML first so model output cannot inject markup.
|
|
let html = text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/\n\n/g, '</p><p>')
|
|
.replace(/\n/g, '<br>');
|
|
return `<p>${html}</p>`;
|
|
}
|
|
|
|
// ── History persistence ─────────────────────────────────────────────
|
|
|
|
_historyKey() {
|
|
const ctx = this._mode === MODE.DOCUMENTS
|
|
? documentsSignature(this._documents)
|
|
: (this._directory || this._vault || 'general');
|
|
return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
|
}
|
|
|
|
_saveHistory() {
|
|
try {
|
|
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
|
|
} catch { /* */ }
|
|
}
|
|
|
|
_loadHistory() {
|
|
try {
|
|
const data = localStorage.getItem(this._historyKey());
|
|
this._messages = data ? JSON.parse(data) : [];
|
|
} catch {
|
|
this._messages = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
const booksLM = new BooksLM();
|
|
export default booksLM;
|
|
export { BooksLM };
|