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
+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() {