feat(ai): rendu Markdown, liens fichiers/paths et sessions (#80)
CI / lint (push) Successful in 57s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m14s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 57s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m14s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Assistant IA (BooksLM) : - renderer Markdown complet (titres, listes, tableaux, citations, code) avec echappement HTML - mentions fichiers/paths cliquables : ouverture du fichier / revelation dans l'arborescence - gestion multi-sessions par contexte (historique, rechargement, suppression, migration) - i18n FR/EN, aide in-app, styles, tests frontend (24)
This commit is contained in:
+573
-32
@@ -9,6 +9,7 @@ import { buildAIPickerUI } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
export const MODE = Object.freeze({
|
||||
DIRECTORY: 'directory',
|
||||
@@ -16,6 +17,13 @@ export const MODE = Object.freeze({
|
||||
GENERAL: 'general',
|
||||
});
|
||||
|
||||
// File extensions recognised when turning a mention into a clickable link.
|
||||
// Anything else that looks like a path is treated as a directory (revealed
|
||||
// in the sidebar tree instead of being opened in the viewer).
|
||||
const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i;
|
||||
// A path-like token: optional `dir/` segments followed by a final segment.
|
||||
const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g;
|
||||
|
||||
/**
|
||||
* Gather the files currently open across the singleton TabManager and every
|
||||
* split-view pane (window globals avoid a circular import with ui.js).
|
||||
@@ -63,6 +71,9 @@ class BooksLM {
|
||||
this._abortCtrl = null;
|
||||
this._panel = null;
|
||||
this._isFullscreen = false;
|
||||
this._sessions = [];
|
||||
this._currentSessionId = null;
|
||||
this._pendingNewSession = false;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
@@ -122,6 +133,8 @@ class BooksLM {
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._closeHistoryMenu();
|
||||
this._renderHistoryMenu();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._panel.classList.add('open');
|
||||
@@ -194,17 +207,27 @@ class BooksLM {
|
||||
}
|
||||
|
||||
newConversation() {
|
||||
this._messages = [];
|
||||
this._saveHistory();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
// Called before the panel exists (command palette): defer the fresh
|
||||
// session until the assistant opens for its actual context.
|
||||
if (!this._panel) {
|
||||
this._pendingNewSession = true;
|
||||
this._messages = [];
|
||||
return;
|
||||
}
|
||||
const session = this._createSession([]);
|
||||
this._sessions.unshift(session);
|
||||
this._currentSessionId = session.id;
|
||||
this._messages = session.messages;
|
||||
this._persistSessions();
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._renderHistoryMenu();
|
||||
}
|
||||
|
||||
exportConversation() {
|
||||
if (!this._messages.length) return;
|
||||
const label = this._contextTitle() || 'assistant';
|
||||
const current = this._currentSession();
|
||||
const label = (current && current.title) || this._contextTitle() || 'assistant';
|
||||
let md = `# ${label}\n\n`;
|
||||
for (const msg of this._messages) {
|
||||
const role = msg.role === 'user' ? '**You**' : '**AI**';
|
||||
@@ -219,9 +242,60 @@ class BooksLM {
|
||||
}
|
||||
|
||||
openFile(path) {
|
||||
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
|
||||
detail: { vault: this._vault, path },
|
||||
}));
|
||||
return this._openFileLink(path);
|
||||
}
|
||||
|
||||
/** Resolve the vault used to open/reveal a link mentioned by the assistant. */
|
||||
_resolveVault() {
|
||||
if (this._vault) return this._vault;
|
||||
try {
|
||||
const docs = collectOpenDocuments();
|
||||
if (docs.length) return docs[0].vault;
|
||||
} catch { /* not ready */ }
|
||||
return state.currentVault || null;
|
||||
}
|
||||
|
||||
/** Open a file mentioned in an assistant answer in the main viewer. */
|
||||
async _openFileLink(path) {
|
||||
const vault = this._resolveVault();
|
||||
if (!vault || !path) {
|
||||
showToast(t('bookslm.no_vault'), 'error');
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('obsigate:open-file', { detail: { vault, path } }));
|
||||
try {
|
||||
const m = await import('./viewer.js');
|
||||
if (m && typeof m.openFile === 'function') await m.openFile(vault, path);
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: open file link failed', e);
|
||||
}
|
||||
// Reposition the tree indicator on the opened file (best-effort).
|
||||
try {
|
||||
const s = await import('./sidebar.js');
|
||||
if (s && typeof s.focusPathInSidebar === 'function') {
|
||||
await s.focusPathInSidebar(vault, path, { alignToTop: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: reveal file in tree failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveal a directory (or file) path in the sidebar tree. */
|
||||
async _revealPath(path) {
|
||||
const vault = this._resolveVault();
|
||||
if (!vault || !path) {
|
||||
showToast(t('bookslm.no_vault'), 'error');
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('obsigate:reveal-in-tree', { detail: { vault, path } }));
|
||||
try {
|
||||
const m = await import('./sidebar.js');
|
||||
if (m && typeof m.focusPathInSidebar === 'function') {
|
||||
await m.focusPathInSidebar(vault, path, { alignToTop: true, expandTarget: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: reveal path failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context ─────────────────────────────────────────────────────────
|
||||
@@ -302,12 +376,14 @@ class BooksLM {
|
||||
<span class="bookslm-subtitle"></span>
|
||||
</div>
|
||||
<div class="bookslm-header-actions">
|
||||
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-fullscreen" title="${t('ai.fullscreen')}" aria-label="${t('ai.fullscreen')}"><i data-lucide="maximize-2" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-close" title="${t('ai.close')}" aria-label="${t('ai.close')}"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bookslm-history-menu hidden"></div>
|
||||
<div class="bookslm-toolbar">
|
||||
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
|
||||
<span class="bookslm-picker-host"></span>
|
||||
@@ -333,8 +409,31 @@ class BooksLM {
|
||||
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleHistoryMenu();
|
||||
});
|
||||
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
|
||||
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
|
||||
|
||||
// Clicking a file/path link inside an assistant answer either opens the
|
||||
// file in the viewer or reveals the path in the sidebar tree.
|
||||
panel.querySelector('.bookslm-messages').addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a.bookslm-path-link');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
const path = link.getAttribute('data-path');
|
||||
if (!path) return;
|
||||
if (link.getAttribute('data-kind') === 'dir') this._revealPath(path);
|
||||
else this._openFileLink(path);
|
||||
});
|
||||
|
||||
// Close the session menu when clicking elsewhere in the panel.
|
||||
panel.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
|
||||
this._closeHistoryMenu();
|
||||
}
|
||||
});
|
||||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||||
this._isFullscreen = !this._isFullscreen;
|
||||
panel.classList.toggle('fullscreen', this._isFullscreen);
|
||||
@@ -710,49 +809,491 @@ class BooksLM {
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
// ── Markdown (lightweight) ──────────────────────────────────────────
|
||||
// ── Markdown (self-contained renderer) ──────────────────────────────
|
||||
//
|
||||
// Renders the assistant answer as formatted HTML (headings, lists, tables,
|
||||
// blockquotes, code, emphasis) without any external dependency. All model
|
||||
// text is HTML-escaped before markup is produced, and file/path mentions
|
||||
// become clickable links handled by the delegated listener in `_render()`.
|
||||
|
||||
_renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Escape HTML first so model output cannot inject markup.
|
||||
let html = text
|
||||
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
|
||||
const out = [];
|
||||
const para = [];
|
||||
let i = 0;
|
||||
|
||||
const flushPara = () => {
|
||||
if (!para.length) return;
|
||||
out.push(`<p>${this._renderInline(para.join('\n'))}</p>`);
|
||||
para.length = 0;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block.
|
||||
const fence = line.match(/^\s*```\s*([\w+#-]*)\s*$/);
|
||||
if (fence) {
|
||||
flushPara();
|
||||
const lang = fence[1] || '';
|
||||
const code = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
|
||||
code.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // closing fence
|
||||
out.push(this._renderCodeBlock(lang, code.join('\n')));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX heading.
|
||||
const heading = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
||||
if (heading) {
|
||||
flushPara();
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${this._renderInline(heading[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule.
|
||||
if (/^\s*(?:[-*_])\s*(?:[-*_]\s*){2,}$/.test(line)) {
|
||||
flushPara();
|
||||
out.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote (may contain any block content).
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
flushPara();
|
||||
const quote = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
quote.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
out.push(`<blockquote>${this._renderMarkdown(quote.join('\n'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table.
|
||||
if (line.includes('|') && i + 1 < lines.length && this._isTableSeparator(lines[i + 1])) {
|
||||
flushPara();
|
||||
const table = this._renderTable(lines, i);
|
||||
out.push(table.html);
|
||||
i = table.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
// List.
|
||||
if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
|
||||
flushPara();
|
||||
const list = this._renderList(lines, i);
|
||||
out.push(list.html);
|
||||
i = list.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushPara();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
para.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
flushPara();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Render the inline (span-level) markup of a single line or paragraph. */
|
||||
_renderInline(text) {
|
||||
if (text == null) return '';
|
||||
const stash = [];
|
||||
const put = (html) => {
|
||||
const idx = stash.length;
|
||||
stash.push(html);
|
||||
return `\u0000${idx}\u0000`;
|
||||
};
|
||||
|
||||
let s = this._escapeHtml(String(text));
|
||||
|
||||
// Inline code — a path wrapped in backticks becomes a clickable link.
|
||||
s = s.replace(/`([^`\n]+)`/g, (_m, code) => {
|
||||
const raw = this._decodeEntities(code);
|
||||
if (this._looksLikePath(raw)) return put(`<code>${this._pathLink(code, raw)}</code>`);
|
||||
return put(`<code>${code}</code>`);
|
||||
});
|
||||
|
||||
// Explicit markdown links: [label](target).
|
||||
s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) =>
|
||||
put(this._renderMdLink(label, target)));
|
||||
|
||||
// Bare `dir/file.ext` mentions.
|
||||
s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) =>
|
||||
`${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`);
|
||||
|
||||
// Emphasis.
|
||||
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
|
||||
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
||||
|
||||
// Preserve single line breaks (chat answers often rely on them).
|
||||
s = s.replace(/\n/g, '<br>');
|
||||
|
||||
// Restore stashed fragments.
|
||||
s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => stash[Number(idx)] || '');
|
||||
return s;
|
||||
}
|
||||
|
||||
_renderCodeBlock(lang, code) {
|
||||
const cls = lang ? ` class="language-${this._escapeAttr(lang)}"` : '';
|
||||
return `<pre><code${cls}>${this._escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
|
||||
_isTableSeparator(line) {
|
||||
if (!line || !line.includes('|') || !line.includes('-')) return false;
|
||||
return line.replace(/[|\s:-]/g, '') === '';
|
||||
}
|
||||
|
||||
_renderTable(lines, start) {
|
||||
const splitRow = (line) =>
|
||||
line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
|
||||
const header = splitRow(lines[start]);
|
||||
const rows = [];
|
||||
let i = start + 2;
|
||||
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
||||
rows.push(splitRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
const thead = header.map((c) => `<th>${this._renderInline(c)}</th>`).join('');
|
||||
const tbody = rows
|
||||
.map((r) => `<tr>${r.map((c) => `<td>${this._renderInline(c)}</td>`).join('')}</tr>`)
|
||||
.join('');
|
||||
return { html: `<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`, next: i };
|
||||
}
|
||||
|
||||
_renderList(lines, start) {
|
||||
const first = lines[start].match(/^(\s*)([-*+]|\d+\.)\s+/);
|
||||
const baseIndent = first ? first[1].length : 0;
|
||||
const ordered = first ? /\d+\./.test(first[2]) : false;
|
||||
const items = [];
|
||||
let i = start;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) break;
|
||||
const m = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
||||
if (!m) break;
|
||||
const indent = m[1].length;
|
||||
if (indent < baseIndent) break;
|
||||
if (indent > baseIndent) {
|
||||
const nested = this._renderList(lines, i);
|
||||
if (items.length) items[items.length - 1].children.push(nested.html);
|
||||
i = nested.next;
|
||||
continue;
|
||||
}
|
||||
let content = m[3];
|
||||
let checkbox = '';
|
||||
const task = content.match(/^\[([ xX])\]\s+(.*)$/);
|
||||
if (task) {
|
||||
checkbox = `<input type="checkbox" disabled${task[1].toLowerCase() === 'x' ? ' checked' : ''}> `;
|
||||
content = task[2];
|
||||
}
|
||||
items.push({ html: checkbox + this._renderInline(content), children: [] });
|
||||
i++;
|
||||
}
|
||||
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
const body = items.map((it) => `<li>${it.html}${it.children.join('')}</li>`).join('');
|
||||
return { html: `<${tag}>${body}</${tag}>`, next: i };
|
||||
}
|
||||
|
||||
_renderMdLink(label, target) {
|
||||
const isExternal = /^(?:https?:|mailto:|tel:|#|\/\/)/i.test(target);
|
||||
if (isExternal) {
|
||||
return `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
}
|
||||
const raw = this._decodeEntities(target).replace(/^\.\//, '');
|
||||
return this._pathLink(label, raw);
|
||||
}
|
||||
|
||||
/** Build a clickable link for a vault-relative file or directory path. */
|
||||
_pathLink(display, rawPath) {
|
||||
const clean = String(rawPath || '').replace(/^\.\//, '');
|
||||
const kind = this._classifyPath(clean);
|
||||
const attr = this._escapeAttr(clean);
|
||||
return `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
|
||||
}
|
||||
|
||||
_classifyPath(path) {
|
||||
return FILE_EXT_RE.test(path || '') ? 'file' : 'dir';
|
||||
}
|
||||
|
||||
_looksLikePath(value) {
|
||||
if (!value) return false;
|
||||
const p = value.trim();
|
||||
if (!p || /\s/.test(p)) return false;
|
||||
if (!/^[\w./\\-]+$/.test(p)) return false;
|
||||
return p.includes('/') || FILE_EXT_RE.test(p);
|
||||
}
|
||||
|
||||
_escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`;
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── History persistence ─────────────────────────────────────────────
|
||||
_escapeAttr(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
_historyKey() {
|
||||
_decodeEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
_contextKey() {
|
||||
const ctx = this._mode === MODE.DOCUMENTS
|
||||
? documentsSignature(this._documents)
|
||||
: (this._directory || this._vault || 'general');
|
||||
return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
||||
return `${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
_sessionsKey() {
|
||||
return `bookslm-sessions-${this._contextKey()}`;
|
||||
}
|
||||
|
||||
_legacyHistoryKey() {
|
||||
return `bookslm-history-${this._contextKey()}`;
|
||||
}
|
||||
|
||||
_newSessionId() {
|
||||
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
_createSession(messages) {
|
||||
const now = Date.now();
|
||||
return { id: this._newSessionId(), title: '', createdAt: now, updatedAt: now, messages: messages || [] };
|
||||
}
|
||||
|
||||
_currentSession() {
|
||||
return this._sessions.find((s) => s.id === this._currentSessionId) || null;
|
||||
}
|
||||
|
||||
_readSessions() {
|
||||
try {
|
||||
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
|
||||
} catch { /* */ }
|
||||
const raw = localStorage.getItem(this._sessionsKey());
|
||||
if (!raw) return [];
|
||||
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,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Import the pre-sessions single conversation, if any. */
|
||||
_migrateLegacyHistory() {
|
||||
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;
|
||||
} 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 : [];
|
||||
}
|
||||
|
||||
_persistSessions() {
|
||||
try {
|
||||
const data = localStorage.getItem(this._historyKey());
|
||||
this._messages = data ? JSON.parse(data) : [];
|
||||
localStorage.setItem(this._sessionsKey(), JSON.stringify(this._sessions));
|
||||
} catch { /* quota / private mode */ }
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
const session = this._currentSession();
|
||||
if (session) {
|
||||
session.messages = this._messages;
|
||||
session.updatedAt = Date.now();
|
||||
if (!session.title) session.title = this._deriveTitle(this._messages);
|
||||
} else {
|
||||
const created = this._createSession(this._messages);
|
||||
created.title = this._deriveTitle(this._messages);
|
||||
this._sessions.unshift(created);
|
||||
this._currentSessionId = created.id;
|
||||
}
|
||||
this._persistSessions();
|
||||
}
|
||||
|
||||
_deriveTitle(messages) {
|
||||
const firstUser = (messages || []).find((m) => m && m.role === 'user' && m.content && m.content.trim());
|
||||
if (!firstUser) return '';
|
||||
const line = firstUser.content.trim().split('\n')[0];
|
||||
return line.length > 60 ? line.slice(0, 57) + '…' : line;
|
||||
}
|
||||
|
||||
_loadSession(id) {
|
||||
const session = this._sessions.find((s) => s.id === id);
|
||||
if (!session) return;
|
||||
this._currentSessionId = id;
|
||||
this._messages = Array.isArray(session.messages) ? session.messages : [];
|
||||
this._closeHistoryMenu();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._renderHistoryMenu();
|
||||
}
|
||||
}
|
||||
|
||||
_deleteSession(id) {
|
||||
this._sessions = this._sessions.filter((s) => s.id !== id);
|
||||
if (!this._sessions.length) {
|
||||
const session = this._createSession([]);
|
||||
this._sessions.push(session);
|
||||
this._currentSessionId = session.id;
|
||||
this._messages = session.messages;
|
||||
} else if (id === this._currentSessionId) {
|
||||
const next = this._sessions[0];
|
||||
this._currentSessionId = next.id;
|
||||
this._messages = next.messages || [];
|
||||
}
|
||||
this._persistSessions();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
this._renderHistoryMenu();
|
||||
}
|
||||
}
|
||||
|
||||
_toggleHistoryMenu() {
|
||||
if (!this._panel) return;
|
||||
const menu = this._panel.querySelector('.bookslm-history-menu');
|
||||
if (!menu) return;
|
||||
const nowHidden = menu.classList.toggle('hidden');
|
||||
if (!nowHidden) this._renderHistoryMenu();
|
||||
}
|
||||
|
||||
_closeHistoryMenu() {
|
||||
if (!this._panel) return;
|
||||
const menu = this._panel.querySelector('.bookslm-history-menu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
_renderHistoryMenu() {
|
||||
if (!this._panel) return;
|
||||
const menu = this._panel.querySelector('.bookslm-history-menu');
|
||||
if (!menu) return;
|
||||
menu.innerHTML = '';
|
||||
|
||||
const sessions = [...this._sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||
if (!sessions.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'bookslm-history-empty';
|
||||
empty.textContent = t('bookslm.history_empty');
|
||||
menu.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'bookslm-history-item';
|
||||
if (session.id === this._currentSessionId) item.classList.add('active');
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'bookslm-history-open';
|
||||
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);
|
||||
open.appendChild(title);
|
||||
open.appendChild(date);
|
||||
open.addEventListener('click', () => this._loadSession(session.id));
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'bookslm-history-delete';
|
||||
del.title = t('bookslm.session_delete');
|
||||
del.setAttribute('aria-label', t('bookslm.session_delete'));
|
||||
del.innerHTML = '<i data-lucide="trash-2" style="width:14px;height:14px"></i>';
|
||||
del.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._deleteSession(session.id);
|
||||
});
|
||||
|
||||
item.appendChild(open);
|
||||
item.appendChild(del);
|
||||
menu.appendChild(item);
|
||||
}
|
||||
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
_formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
this._messages = [];
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user