feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s

This commit is contained in:
2026-09-16 14:56:45 -04:00
parent 0841778b99
commit c9e3240ae2
23 changed files with 1351 additions and 151 deletions
+31
View File
@@ -917,6 +917,20 @@
style="width: 18px; height: 18px"
></i>
</button>
<button
class="sidebar-tab"
id="sidebar-tab-ai"
role="tab"
aria-selected="false"
aria-controls="sidebar-panel-ai"
data-tab="ai"
title="Historique IA"
>
<i
data-lucide="messages-square"
style="width: 18px; height: 18px"
></i>
</button>
</div>
<!-- Vaults panel -->
@@ -1021,6 +1035,23 @@
<span>Aucune recherche sauvegardee</span>
</div>
</div>
<!-- AI history panel -->
<div
class="sidebar-tab-panel"
id="sidebar-panel-ai"
role="tabpanel"
aria-labelledby="sidebar-tab-ai"
>
<div id="ai-history-list" class="recent-list"></div>
<div id="ai-history-empty" class="recent-empty hidden">
<i
data-lucide="messages-square"
style="width: 32px; height: 32px"
></i>
<span>Aucune conversation</span>
</div>
</div>
</aside>
<!-- Sidebar resize handle -->
+496 -66
View File
@@ -129,6 +129,14 @@ class BooksLM {
this._pathCache = null;
this._pathCacheVault = null;
this._pathCachePromise = null;
// #95 — Debounced server sync of the session history (persistent backend).
this._syncTimer = null;
this._syncInFlight = false;
this._dirtySessionIds = new Set();
this._deletedIds = new Set();
// #97 — Extensible "+" panel entries. A plain array so future plugins can
// push their own options at runtime (modular by construction).
this._extensions = null;
}
_readAgentMode() {
@@ -167,14 +175,17 @@ class BooksLM {
* @param {string|null} [opts.vault]
* @param {string} [opts.directory]
* @param {Array<{vault:string,path:string}>} [opts.documents]
* @param {string|null} [preferredSessionId] #95/#96 — when set, this session
* is selected (and hydrated) instead of the latest of the context.
*/
async openContext(opts = {}) {
async openContext(opts = {}, preferredSessionId = null) {
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 =
preferredSessionId == null &&
this._isOpen &&
this._mode === mode &&
this._vault === vault &&
@@ -204,7 +215,7 @@ class BooksLM {
this._images = [];
this._activeSkill = null;
this._isLoading = true;
this._loadHistory();
await this._loadHistory(preferredSessionId);
if (!this._panel) {
this._panel = this._render();
@@ -243,6 +254,29 @@ class BooksLM {
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
}
/**
* #95/#96 — Open a specific conversation from the persistent history.
* @param {string|object} sessionOrId — full session object or session id.
*/
async openWithSession(sessionOrId) {
const id = typeof sessionOrId === 'string' ? sessionOrId : (sessionOrId && sessionOrId.id);
if (!id) return;
let session = typeof sessionOrId === 'object' ? sessionOrId : null;
if (!session || !Array.isArray(session.messages)) {
const fetched = await this._fetchSessionFromServer(id).catch(() => null);
if (fetched) session = this._normalizeSession(fetched);
}
if (!session) session = this._sessions.find((s) => s.id === id) || null;
if (!session) return;
this._positionSession(session);
await this.openContext({
mode: session.mode || MODE.GENERAL,
vault: session.vault || null,
directory: session.directory || '',
documents: Array.isArray(session.documents) ? session.documents : [],
}, id);
}
/**
* Open the assistant using the current application context: the open
* document(s) if any, otherwise the General assistant.
@@ -308,6 +342,7 @@ class BooksLM {
this._sessions.unshift(session);
this._currentSessionId = session.id;
this._messages = session.messages;
this._dirtySessionIds.add(session.id);
this._persistSessions();
this._renderMessages();
this._showSuggestions();
@@ -884,11 +919,15 @@ class BooksLM {
<div class="bookslm-mention-menu hidden" role="listbox" aria-label="${t('ai.add_context')}"></div>
</div>
<div class="bookslm-input-area">
<button class="bookslm-btn-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
<button class="bookslm-btn-plus" title="${t('bookslm.add_options')}" aria-label="${t('bookslm.add_options')}"><i data-lucide="plus" style="width:18px;height:18px"></i></button>
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
<input type="file" class="bookslm-files-input" multiple hidden>
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('ai.composer_label')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">✈️</button>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">
<i data-lucide="arrow-up" style="width:18px;height:18px"></i>
</button>
</div>
<div class="bookslm-ext-menu hidden"></div>
`;
// Provider/model picker lives on its own toolbar row so the header
@@ -970,16 +1009,31 @@ class BooksLM {
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
// Image attachment button (pick image files from disk).
const attachBtn = panel.querySelector('.bookslm-btn-attach');
// #97 — The "+" button opens the extensible options panel; the panel can
// attach images (existing picker), upload any file, or trigger commands.
const plusBtn = panel.querySelector('.bookslm-btn-plus');
const fileInput = panel.querySelector('.bookslm-file-input');
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => fileInput.click());
const filesInput = panel.querySelector('.bookslm-files-input');
if (plusBtn) plusBtn.addEventListener('click', () => this._toggleExtMenu());
if (fileInput) {
fileInput.addEventListener('change', () => {
this._addImageFiles(fileInput.files);
fileInput.value = '';
});
}
if (filesInput) {
filesInput.addEventListener('change', () => {
this._addUploadedFiles(filesInput.files);
filesInput.value = '';
});
}
const extMenu = panel.querySelector('.bookslm-ext-menu');
if (extMenu) {
panel.addEventListener('click', (e) => {
if (e.target.closest('.bookslm-ext-menu') || e.target.closest('.bookslm-btn-plus')) return;
this._closeExtMenu();
});
}
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
@@ -1263,6 +1317,151 @@ class BooksLM {
Array.from(files).forEach((file) => this._addImageFile(file));
}
/** #97 — Leverage the drop-zone logic for files picked in the "+" panel. */
async _addUploadedFiles(files) {
if (!files || !files.length) return;
if (!this._isOpen) await this.openForCurrentContext();
const items = Array.from(files).map((file) => ({ path: file.name, file, isDir: false }));
await this.handleExternalDroppedItems(items);
this._closeExtMenu();
}
// ── Extensible "+" panel (#97) ──────────────────────────────────────
//
// The panel is driven by a plain array of entries. Each entry is
// {id, icon, label, hint, disabled, action} — future plugins only need to
// push their own entry to appear here.
_extensionItems() {
if (this._extensions) return this._extensions;
this._extensions = [
{
id: 'files',
icon: 'file-up',
label: t('bookslm.ext_files'),
hint: t('bookslm.ext_files_hint'),
action: () => {
const input = this._panel && this._panel.querySelector('.bookslm-files-input');
if (input) input.click();
},
},
{
id: 'image',
icon: 'image-plus',
label: t('ai.attach_image'),
hint: t('bookslm.ext_image_hint'),
action: () => {
const input = this._panel && this._panel.querySelector('.bookslm-file-input');
if (input) input.click();
},
},
{
id: 'context',
icon: 'folder-plus',
label: t('bookslm.ext_contexts'),
hint: t('bookslm.ext_contexts_hint'),
action: () => {
this._closeExtMenu();
this._showMentionMenu('');
},
},
{
id: 'skill',
icon: 'wand-2',
label: t('bookslm.ext_skills'),
hint: t('bookslm.ext_skills_hint'),
action: () => {
this._closeExtMenu();
this._showCommandMenu('');
},
},
{
id: 'deep_research',
icon: 'search-check',
label: t('bookslm.ext_deep_research'),
hint: t('bookslm.ext_deep_research_hint'),
action: () => this._startDeepResearch(),
},
{
id: 'web',
icon: 'globe',
label: t('bookslm.ext_web'),
hint: t('bookslm.ext_web_hint'),
disabled: true,
action: () => showToast(t('bookslm.coming_soon'), 'info'),
},
{
id: 'canva',
icon: 'palette',
label: t('bookslm.ext_canva'),
hint: t('bookslm.ext_canva_hint'),
disabled: true,
action: () => showToast(t('bookslm.coming_soon'), 'info'),
},
];
return this._extensions;
}
_toggleExtMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-ext-menu');
if (!menu) return;
const nowHidden = menu.classList.toggle('hidden');
if (!nowHidden) this._renderExtMenu();
}
_closeExtMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-ext-menu');
if (menu) menu.classList.add('hidden');
}
_renderExtMenu() {
if (!this._panel) return;
const menu = this._panel.querySelector('.bookslm-ext-menu');
if (!menu) return;
menu.innerHTML = '';
for (const entry of this._extensionItems()) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'bookslm-ext-item';
if (entry.disabled) item.classList.add('disabled');
item.dataset.extId = entry.id;
item.innerHTML = `
<i data-lucide="${entry.icon}" style="width:16px;height:16px"></i>
<span class="bookslm-ext-label">${entry.label}</span>
${entry.disabled ? '<span class="bookslm-ext-soon">' + t('bookslm.soon') + '</span>' : ''}
<span class="bookslm-ext-hint">${entry.hint}</span>
`;
item.addEventListener('click', () => {
if (!entry.disabled && entry.action) entry.action();
});
menu.appendChild(item);
}
const footer = document.createElement('div');
footer.className = 'bookslm-ext-footer';
footer.textContent = t('bookslm.ext_more_coming');
menu.appendChild(footer);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** #97 — Deep Research: enabled agent tools + a pre-filled directive. */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
const textarea = this._panel && this._panel.querySelector('textarea');
if (textarea) {
const directive = textarea.value.trim()
? textarea.value.replace(/\s*$/, '')
: '';
textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
} else {
showToast(t('bookslm.deep_research_started'), 'info');
}
}
_onPaste(e) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
@@ -2978,10 +3177,12 @@ class BooksLM {
// ── 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.
// #95 — Conversations are persisted server-side
// (`/api/ai/bookslm/history`), localStorage being kept as an offline cache.
// A session is `{id, title, mode, vault, directory, documents, context,
// createdAt, updatedAt, messages}`; messages may be `null` while a summary
// coming from the server has not been hydrated yet. The header history
// button and the sidebar tab (#96) both list every session of the user.
_contextKey() {
const ctx = this._mode === MODE.DOCUMENTS
@@ -2990,7 +3191,20 @@ class BooksLM {
return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
}
_userScopedKey(base) {
let user = '';
try {
const u = AuthManager.getUser ? AuthManager.getUser() : null;
if (u && u.username) user = u.username;
} catch { /* ignore */ }
return `${base}-${user || 'anon'}`;
}
_sessionsKey() {
return this._userScopedKey('bookslm-sessions-all');
}
_legacySessionsKey() {
return `bookslm-sessions-${this._contextKey()}`;
}
@@ -3004,13 +3218,57 @@ class BooksLM {
_createSession(messages) {
const now = Date.now();
return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] };
return {
id: this._newSessionId(),
title: '',
mode: this._mode,
vault: this._vault,
directory: this._directory,
documents: Array.isArray(this._documents) ? this._documents : [],
context: this._contextKey(),
createdAt: now,
updatedAt: now,
messages: messages || [],
};
}
_currentSession() {
return this._sessions.find((s) => s.id === this._currentSessionId) || null;
}
_normalizeSession(s) {
const messages = Array.isArray(s.messages)
? s.messages
: (s.messages == null ? null : []);
return {
id: s.id || this._newSessionId(),
title: s.title || '',
mode: s.mode || MODE.GENERAL,
vault: s.vault || null,
directory: s.directory || '',
documents: Array.isArray(s.documents) ? s.documents : [],
context: s.context || '',
createdAt: s.createdAt || Date.now(),
updatedAt: s.updatedAt || s.createdAt || Date.now(),
messages,
};
}
/** Insert or update `session`, keeping the list sorted by updatedAt. */
_positionSession(session) {
let replaced = false;
for (let i = 0; i < this._sessions.length; i++) {
if (this._sessions[i].id === session.id) {
this._sessions[i] = session;
replaced = true;
break;
}
}
if (!replaced) this._sessions.push(session);
this._sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
/** Read the offline cache (localStorage). */
_readSessions() {
try {
const raw = localStorage.getItem(this._sessionsKey());
@@ -3018,78 +3276,215 @@ class BooksLM {
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,
}));
.filter((s) => s && typeof s === 'object')
.map((s) => this._normalizeSession(s));
} catch {
return [];
}
}
/** Import the pre-sessions single conversation, if any. */
/** Import the pre-#95 per-context keys into the global store, once. */
_migrateLegacyHistory() {
const migrated = [];
try {
const raw = localStorage.getItem(this._legacySessionsKey());
if (raw) {
const list = JSON.parse(raw);
if (Array.isArray(list)) {
for (const s of list) {
if (!s || typeof s !== 'object') continue;
if (!s.context) s.context = this._contextKey();
if (!s.mode) s.mode = this._mode;
migrated.push(this._normalizeSession(s));
}
}
localStorage.removeItem(this._legacySessionsKey());
}
} catch { /* ignore */ }
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;
if (raw) {
const messages = JSON.parse(raw);
if (Array.isArray(messages) && messages.length) {
const session = this._createSession(messages);
session.title = this._deriveTitle(messages);
migrated.push(session);
}
localStorage.removeItem(this._legacyHistoryKey());
}
} catch { /* ignore */ }
return migrated;
}
// ── Server round-trips (#95) ────────────────────────────────────────
async _fetchHistorySummaryFromServer() {
const data = await api('/api/ai/bookslm/history');
return Array.isArray(data.sessions) ? data.sessions : [];
}
async _fetchSessionFromServer(id) {
try {
return await api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`);
} 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 : [];
_persistableMessages(messages) {
return (messages || []).map(({ confirmation, payload, ...rest }) => rest);
}
_persistSessions() {
async _upsertSessionToServer(session) {
const payload = {
id: session.id,
title: session.title || '',
mode: session.mode || MODE.GENERAL,
vault: session.vault ?? null,
directory: session.directory || '',
documents: Array.isArray(session.documents) ? session.documents : [],
context: session.context || '',
createdAt: session.createdAt,
updatedAt: session.updatedAt,
messages: this._persistableMessages(session.messages),
};
return api(`/api/ai/bookslm/history/${encodeURIComponent(payload.id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
async _deleteSessionFromServer(id) {
return api(`/api/ai/bookslm/history/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
/** Debounced best-effort push of local changes to the server. */
_syncHistoryToServer() {
if (this._syncTimer) clearTimeout(this._syncTimer);
this._syncTimer = setTimeout(() => {
this._syncTimer = null;
this._flushHistoryToServer();
}, 600);
}
async _flushHistoryToServer() {
if (this._syncInFlight) return;
this._syncInFlight = true;
try {
for (const id of [...this._deletedIds]) {
try {
await this._deleteSessionFromServer(id);
this._deletedIds.delete(id);
} catch { /* offline: retry next time */ }
}
for (const session of this._sessions) {
if (!this._dirtySessionIds.has(session.id)) continue;
try {
await this._upsertSessionToServer(session);
this._dirtySessionIds.delete(session.id);
} catch { /* offline: keep dirty, retry next time */ }
}
} finally {
this._syncInFlight = false;
this._notifyHistoryChanged();
}
}
_persistLocalCache() {
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);
/** Local cache + schedule the server sync (#95). */
_persistSessions() {
this._persistLocalCache();
this._syncHistoryToServer();
this._notifyHistoryChanged();
}
/** Broad(cast) notification consumed by the sidebar history tab (#96). */
_notifyHistoryChanged() {
try {
window.dispatchEvent(new CustomEvent('bookslm:history-updated'));
} catch { /* ignore */ }
}
async _loadHistory(preferredSessionId = null) {
// 1. Local cache first (fast, offline-safe).
this._sessions = this._readSessions();
for (const migrated of this._migrateLegacyHistory()) this._positionSession(migrated);
// 2. Merge the server list (authoritative metadata); sessions present
// locally but unknown to the server are marked dirty and re-uploaded.
try {
const remote = await this._fetchHistorySummaryFromServer();
if (remote.length) {
const remoteIds = new Set();
const remoteBy = new Map();
for (const r of remote) {
remoteIds.add(r.id);
remoteBy.set(r.id, r);
}
for (const s of this._sessions) {
if (!remoteIds.has(s.id)) this._dirtySessionIds.add(s.id);
}
for (const r of remote) {
if (remoteBy.has(r.id) && this._sessions.some((s) => s.id === r.id)) continue;
this._positionSession(this._normalizeSession({ ...r, messages: null }));
}
this._persistLocalCache();
}
} catch { /* offline: keep the localStorage cache */ }
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;
const created = this._createSession([]);
this._positionSession(created);
this._dirtySessionIds.add(created.id);
this._persistSessions();
} else if (!this._sessions.length) {
const created = this._createSession([]);
this._positionSession(created);
this._dirtySessionIds.add(created.id);
this._persistSessions();
}
// 3. Pick the session to display: the requested one, else the latest of
// the current context, else any conversation.
let current = preferredSessionId
? this._sessions.find((s) => s.id === preferredSessionId) || null
: this._sessions.find((s) => s.context === this._contextKey()) || this._sessions[0] || null;
if (!current) {
current = this._createSession([]);
this._positionSession(current);
} else if (!Array.isArray(current.messages)) {
const full = await this._fetchSessionFromServer(current.id);
current.messages = full && Array.isArray(full.messages) ? full.messages : [];
this._persistLocalCache();
}
this._currentSessionId = current.id;
this._messages = current.messages || [];
this._notifyHistoryChanged();
}
_saveHistory() {
const persisted = this._persistableMessages();
const persisted = this._persistableMessages(this._messages);
const session = this._currentSession();
if (session) {
session.messages = persisted;
session.updatedAt = Date.now();
if (!session.title) session.title = this._deriveTitle(this._messages);
this._dirtySessionIds.add(session.id);
this._positionSession(session);
} else {
const created = this._createSession(persisted);
created.title = this._deriveTitle(this._messages);
this._sessions.unshift(created);
this._positionSession(created);
this._currentSessionId = created.id;
this._dirtySessionIds.add(created.id);
}
this._persistSessions();
}
@@ -3101,31 +3496,51 @@ class BooksLM {
return line.length > 60 ? line.slice(0, 57) + '…' : line;
}
_loadSession(id) {
const session = this._sessions.find((s) => s.id === id);
/** Select a session, hydrating it from the server when only a summary. */
async _selectSessionById(id) {
let session = this._sessions.find((s) => s.id === id) || null;
if (!session || !Array.isArray(session.messages)) {
const fetched = await this._fetchSessionFromServer(id);
if (fetched && Array.isArray(fetched.messages)) {
session = this._normalizeSession(fetched);
this._positionSession(session);
} else if (session) {
session.messages = session.messages || [];
}
}
if (!session) return;
this._currentSessionId = id;
this._currentSessionId = session.id;
this._messages = Array.isArray(session.messages) ? session.messages : [];
this._closeHistoryMenu();
if (this._panel) {
this._updateHeader();
this._renderMessages({ anchor: true });
this._showSuggestions();
this._renderHistoryMenu();
}
this._persistLocalCache();
}
async _loadSession(id) {
await this._selectSessionById(id);
}
_deleteSession(id) {
this._sessions = this._sessions.filter((s) => s.id !== id);
this._dirtySessionIds.delete(id);
this._deletedIds.add(id);
let session;
if (!this._sessions.length) {
const session = this._createSession([]);
session = this._createSession([]);
this._sessions.push(session);
this._currentSessionId = session.id;
this._messages = session.messages;
this._dirtySessionIds.add(session.id);
} else if (id === this._currentSessionId) {
const next = this._sessions[0];
this._currentSessionId = next.id;
this._messages = next.messages || [];
session = this._sessions[0];
} else {
session = this._currentSession() || this._sessions[0];
}
this._currentSessionId = session.id;
this._messages = session.messages || [];
this._persistSessions();
if (this._panel) {
this._renderMessages();
@@ -3174,11 +3589,13 @@ class BooksLM {
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);
const meta = document.createElement('span');
meta.className = 'bookslm-history-meta';
const label = this._historyContextLabel(session);
const date = this._formatDate(session.updatedAt);
meta.textContent = label ? `${label} · ${date}` : date;
open.appendChild(title);
open.appendChild(date);
open.appendChild(meta);
open.addEventListener('click', () => this._loadSession(session.id));
const del = document.createElement('button');
@@ -3208,6 +3625,19 @@ class BooksLM {
return '';
}
}
/** Short context reachable from a session, used by the history menu. */
_historyContextLabel(session) {
if (!session) return '';
const mode = session.mode || MODE.GENERAL;
if (mode === MODE.DIRECTORY) {
if (session.directory) return `📁 ${session.directory}`;
if (session.vault) return `📁 ${session.vault}`;
return t('bookslm.mode_directory');
}
if (mode === MODE.DOCUMENTS) return t('bookslm.mode_documents');
return t('bookslm.mode_general');
}
}
const booksLM = new BooksLM();
+76 -1
View File
@@ -147,6 +147,73 @@ function initRecentTab() {
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
}
// ---------------------------------------------------------------------------
// AI history sidebar tab (#96)
// ---------------------------------------------------------------------------
function _formatAIDate(ts) {
if (!ts) return "";
try {
return new Date(ts).toLocaleString();
} catch {
return "";
}
}
export async function loadAISessionList() {
const listEl = document.getElementById("ai-history-list");
const emptyEl = document.getElementById("ai-history-empty");
if (!listEl) return;
let sessions = [];
try {
const data = await api("/api/ai/bookslm/history");
sessions = data.sessions || [];
} catch (err) {
console.error("Failed to load AI history:", err);
}
renderAIHistoryList(sessions);
}
function renderAIHistoryList(sessions) {
const listEl = document.getElementById("ai-history-list");
const emptyEl = document.getElementById("ai-history-empty");
if (!listEl) return;
listEl.innerHTML = "";
if (!sessions || sessions.length === 0) {
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
}
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
sessions.forEach((s) => {
const item = el("div", { class: "recent-item", "data-session-id": s.id });
const header = el("div", { class: "recent-item-header" });
const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(_formatAIDate(s.updatedAt))]);
header.appendChild(timeSpan);
header.appendChild(el("span", { class: "recent-vault-badge" }, [document.createTextNode(t("bookslm.mode_" + (s.mode || "general"))) ]));
item.appendChild(header);
const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(s.title || t("bookslm.untitled"))]);
item.appendChild(titleEl);
if (s.preview) {
item.appendChild(el("div", { class: "recent-item-preview" }, [document.createTextNode(s.preview)]));
}
item.addEventListener("click", () => {
closeMobileSidebar();
import("./bookslm.js").then((m) => m.default.openWithSession(s.id)).catch((e) => console.error(e));
});
listEl.appendChild(item);
});
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Sidebar tabs
// ---------------------------------------------------------------------------
@@ -154,6 +221,10 @@ function initSidebarTabs() {
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab));
});
// #96 — Refresh the sidebar AI history list whenever a conversation changes.
window.addEventListener("bookslm:history-updated", () => {
if (state.activeSidebarTab === "ai") loadAISessionList();
});
}
function switchSidebarTab(tab) {
@@ -169,7 +240,7 @@ function switchSidebarTab(tab) {
});
const filterInput = document.getElementById("sidebar-filter-input");
if (filterInput) {
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" };
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: "" };
filterInput.placeholder = placeholders[tab] || "";
}
const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
@@ -183,6 +254,10 @@ function switchSidebarTab(tab) {
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
// #96 — Auto-load the AI conversation history when entering its tab.
if (tab === "ai") {
loadAISessionList();
}
}
function initHelpModal() {
+22
View File
@@ -1829,6 +1829,28 @@
"bookslm.untitled": "Untitled conversation",
"bookslm.no_vault": "No active vault to open this link",
"bookslm.copied_name": "Name copied: {name}",
"bookslm.add_options": "Add options",
"bookslm.ext_files": "Upload files",
"bookslm.ext_files_hint": "Add files to the conversation context",
"bookslm.ext_image_hint": "Attach an image for vision-capable models",
"bookslm.ext_contexts": "Add contexts",
"bookslm.ext_contexts_hint": "Attach files or directories with the @ command",
"bookslm.ext_skills": "Add skills",
"bookslm.ext_skills_hint": "Select an active skill",
"bookslm.ext_deep_research": "Deep Research",
"bookslm.ext_deep_research_hint": "Multi-step in-depth research",
"bookslm.ext_web": "Internet search",
"bookslm.ext_web_hint": "Coming soon",
"bookslm.ext_canva": "Canva",
"bookslm.ext_canva_hint": "Coming soon",
"bookslm.coming_soon": "Feature coming soon",
"bookslm.soon": "Soon",
"bookslm.ext_more_coming": "More options coming…",
"bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.",
"bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.",
"bookslm.mode_general": "General",
"bookslm.mode_directory": "Directory",
"bookslm.mode_documents": "Documents",
"palette.bookslm_open": "BooksLM: Open for current directory",
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
"palette.bookslm_new": "BooksLM: New conversation",
+22
View File
@@ -1829,6 +1829,28 @@
"bookslm.untitled": "Conversation sans titre",
"bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien",
"bookslm.copied_name": "Nom copié : {name}",
"bookslm.add_options": "Options d'ajout",
"bookslm.ext_files": "Téléverser des fichiers",
"bookslm.ext_files_hint": "Ajouter des fichiers au contexte de la conversation",
"bookslm.ext_image_hint": "Joindre une image pour les modèles à vision",
"bookslm.ext_contexts": "Ajouter des contextes",
"bookslm.ext_contexts_hint": "Joindre des fichiers ou répertoires via la commande @",
"bookslm.ext_skills": "Ajouter des skills",
"bookslm.ext_skills_hint": "Sélectionner un skill actif",
"bookslm.ext_deep_research": "Deep Research",
"bookslm.ext_deep_research_hint": "Recherche approfondie à étapes multiples",
"bookslm.ext_web": "Recherche sur Internet",
"bookslm.ext_web_hint": "Bientôt disponible",
"bookslm.ext_canva": "Canva",
"bookslm.ext_canva_hint": "Bientôt disponible",
"bookslm.coming_soon": "Fonctionnalité à venir",
"bookslm.soon": "Bientôt",
"bookslm.ext_more_coming": "D'autres options à venir…",
"bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.",
"bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.",
"bookslm.mode_general": "Général",
"bookslm.mode_directory": "Répertoire",
"bookslm.mode_documents": "Documents",
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
"palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
+26 -4
View File
@@ -9526,7 +9526,9 @@ body.popup-mode .content-area {
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
.bookslm-input-area button.bookslm-btn-send { padding: 8px 12px; font-size: 16px; line-height: 1; }
.bookslm-input-area button.bookslm-btn-send { width: 40px; height: 40px; padding: 0;
border-radius: 50%; font-size: 16px; line-height: 1; display: flex; align-items: center;
justify-content: center; flex-shrink: 0; }
.bookslm-input-area button:hover { opacity: 0.9; }
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
@@ -9650,6 +9652,8 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; }
color: var(--text-primary); border-radius: 8px; }
.bookslm-history-title { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-history-date { font-size: 10px; color: var(--text-secondary); }
.bookslm-history-meta { font-size: 10px; color: var(--text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-history-delete { background: none; border: none; cursor: pointer; color: var(--text-secondary);
padding: 0 8px; border-radius: 6px; display: flex; align-items: center; }
.bookslm-history-delete:hover { color: #f87171; background: rgba(248,113,113,0.12); }
@@ -9692,11 +9696,29 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
white-space: nowrap; }
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
/* Image attach button. */
.bookslm-btn-attach { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
border-radius: 8px; padding: 7px; }
.bookslm-btn-attach:hover { color: var(--accent); border-color: var(--accent); }
border-radius: 50%; width: 32px; height: 32px; padding: 0; }
.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
.bookslm-file-input { display: none; }
.bookslm-files-input { display: none; }
/* Extensible "+" panel (#97). */
.bookslm-ext-menu { position: absolute; left: 12px; right: 12px; bottom: 72px; z-index: 45;
max-height: 55vh; overflow-y: auto; background: var(--bg-primary); border: 1px solid var(--border);
border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); padding: 6px; }
.bookslm-ext-menu.hidden { display: none; }
.bookslm-ext-item { display: grid; grid-template-columns: 20px 1fr auto; align-items: center;
gap: 8px; width: 100%; text-align: left; background: none; border: none; cursor: pointer;
padding: 8px 9px; border-radius: 8px; color: var(--text-primary); font-size: 13px; }
.bookslm-ext-item:hover { background: var(--bg-hover); }
.bookslm-ext-item .bookslm-ext-hint { grid-column: 2 / 4; font-size: 11px; color: var(--text-secondary);
margin-top: -4px; }
.bookslm-ext-item.disabled { opacity: 0.55; cursor: not-allowed; }
.bookslm-ext-item.disabled:hover { background: none; }
.bookslm-ext-soon { font-size: 9px; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--accent); border: 1px solid var(--accent); border-radius: 10px; padding: 1px 6px; }
.bookslm-ext-footer { padding: 7px 9px 4px; font-size: 10px; color: var(--text-secondary);
text-align: center; border-top: 1px dashed var(--border); margin-top: 4px; }
/* Create-skill modal. */
.bookslm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 2000;
display: flex; align-items: center; justify-content: center; padding: 16px; }