feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
This commit is contained in:
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user