L'assistant IA peut desormais inserer sa reponse dans l'editeur Forge (postMessage parent-insert) en plus d'Editer, et chaque bloc de code propose un bouton « Ajouter la section » pour n'inserer que ce bloc.
3716 lines
137 KiB
JavaScript
3716 lines
137 KiB
JavaScript
// ObsiGate — AI Assistant sidebar.
|
||
//
|
||
// The panel adapts to three contexts:
|
||
// • directory — a vault folder (opened from the tree context menu)
|
||
// • documents — the file(s) currently open in tabs/panes
|
||
// • general — no document open: app help + file/directory creation actions
|
||
import { t } from './i18n.js';
|
||
import { buildAIPickerUI, getModelCapabilities } 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',
|
||
DOCUMENTS: 'documents',
|
||
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.
|
||
// Unicode-aware (`\p{L}\p{N}\p{M}`) so accented names — either precomposed
|
||
// (é) or decomposed (e + combining acute) — are recognised.
|
||
const PATH_CHARS = '[\\p{L}\\p{N}\\p{M}._-]';
|
||
const PATH_WITH_DIR_RE = new RegExp(
|
||
`(^|[\\s(>"'])((?:${PATH_CHARS}+\\/)+${PATH_CHARS}+\\.[A-Za-z0-9]{1,8})`,
|
||
'gu',
|
||
);
|
||
// Same, but each segment may contain spaces (`Mon dossier/Ma note.md`). Matched
|
||
// only when the candidate resolves against the vault path index so ordinary
|
||
// prose is never turned into a link.
|
||
const PATH_WITH_SPACES_RE = /(^|[\s(>"'\[])((?:[^\s/]+(?:[ \t]+[^\s/]+)*\/)+[^\s/]+(?:[ \t]+[^\s/]+)*\.[A-Za-z0-9]{1,8})/g;
|
||
// Characters allowed in a single file/directory name (accented letters
|
||
// included). Used to tell a path from prose or a code snippet.
|
||
const PATH_NAME_RE = /^[\p{L}\p{N}\p{M}._/\\ ()'&+-]+$/u;
|
||
// Image files that can be attached to a vision-capable model.
|
||
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i;
|
||
// Resizable panel bounds (px).
|
||
const PANEL_MIN_WIDTH = 320;
|
||
const PANEL_MAX_WIDTH = 1000;
|
||
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
|
||
|
||
/**
|
||
* Accent- and case-insensitive normalization used to match `/` commands and
|
||
* `@` mentions: skill ids/labels and vault paths may contain accented
|
||
* characters (é, à, ç…), which the composer regexes and filters must accept.
|
||
*/
|
||
function normalizeSearch(value) {
|
||
return String(value || '')
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Gather the files currently open across the singleton TabManager and every
|
||
* split-view pane (window globals avoid a circular import with ui.js).
|
||
* The active tab of each pane is listed first.
|
||
*/
|
||
export function collectOpenDocuments() {
|
||
const docs = [];
|
||
const seen = new Set();
|
||
const add = (vault, path) => {
|
||
if (!vault || !path) return;
|
||
const key = `${vault}\u0000${path}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
docs.push({ vault, path });
|
||
};
|
||
const scan = (tm) => {
|
||
if (!tm || !Array.isArray(tm._tabs)) return;
|
||
const active = tm._tabs.find((x) => x.id === tm._activeTabId);
|
||
if (active) add(active.vault, active.path);
|
||
for (const tab of tm._tabs) add(tab.vault, tab.path);
|
||
};
|
||
try {
|
||
scan(window.TabManager);
|
||
const pm = window.PaneManager;
|
||
if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan);
|
||
} catch { /* not ready yet */ }
|
||
return docs;
|
||
}
|
||
|
||
export function documentsSignature(documents) {
|
||
return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000');
|
||
}
|
||
|
||
class BooksLM {
|
||
constructor() {
|
||
this._isOpen = false;
|
||
this._mode = MODE.GENERAL;
|
||
this._vault = null;
|
||
this._directory = null;
|
||
this._documents = [];
|
||
this._effectiveScope = MODE.GENERAL;
|
||
this._messages = [];
|
||
this._contextFiles = [];
|
||
this._isLoading = false;
|
||
this._abortCtrl = null;
|
||
this._panel = null;
|
||
this._isFullscreen = false;
|
||
this._sessions = [];
|
||
this._currentSessionId = null;
|
||
this._pendingNewSession = false;
|
||
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
|
||
// call read/search tools and propose mutations (confirmation cards).
|
||
this._agentMode = this._readAgentMode();
|
||
// Ad-hoc context added with `@` (files/directories) and images attached
|
||
// by paste or by mentioning an image file.
|
||
this._adhocFiles = [];
|
||
this._adhocDirs = [];
|
||
this._images = [];
|
||
this._activeSkill = null;
|
||
// Deep Research: a skill-like chip that enables agent tools and injects a
|
||
// research directive into the request (without polluting the composer).
|
||
this._activeDeepResearch = false;
|
||
this._skills = [];
|
||
this._menuItems = [];
|
||
this._menuIndex = 0;
|
||
this._menuKind = null;
|
||
// Monotonic token used to drop stale async menu renders (fast typing).
|
||
this._menuSeq = 0;
|
||
// Timer that hides the "done" activity indicator after a short delay.
|
||
this._activityHideTimer = null;
|
||
// True while the latest question should stay pinned to the top of the
|
||
// thread (cleared as soon as the user scrolls manually).
|
||
this._pinnedTurn = false;
|
||
// Cache of every indexed path per vault, backing instant `@` filtering.
|
||
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() {
|
||
try {
|
||
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_toggleAgentMode() {
|
||
this._agentMode = !this._agentMode;
|
||
try {
|
||
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
|
||
} catch { /* private mode */ }
|
||
this._updateAgentToggle();
|
||
}
|
||
|
||
_updateAgentToggle() {
|
||
if (!this._panel) return;
|
||
const btn = this._panel.querySelector('.bookslm-btn-agent');
|
||
if (!btn) return;
|
||
btn.classList.toggle('active', this._agentMode);
|
||
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
|
||
btn.title = label;
|
||
btn.setAttribute('aria-label', label);
|
||
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
|
||
}
|
||
|
||
// ── Public API ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Open the assistant for a specific context.
|
||
* @param {object} opts
|
||
* @param {'directory'|'documents'|'general'} opts.mode
|
||
* @param {string|null} [opts.vault]
|
||
* @param {string} [opts.directory]
|
||
* @param {Array<{vault:string,path:string}>} [opts.documents]
|
||
* @param {string|null} [preferredSessionId] #95/#96 — when set, this session
|
||
* is selected (and hydrated) instead of the latest of the context.
|
||
*/
|
||
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 &&
|
||
this._directory === directory &&
|
||
documentsSignature(this._documents) === documentsSignature(documents);
|
||
|
||
if (sameContext) {
|
||
// Already open for this context: reveal a collapsed panel, else close.
|
||
if (this._panel && this._panel.classList.contains('hidden')) {
|
||
this._setHidden(false);
|
||
document.dispatchEvent(new CustomEvent('bookslm:opened'));
|
||
} else {
|
||
this.close();
|
||
}
|
||
return;
|
||
}
|
||
|
||
this._mode = mode;
|
||
this._vault = vault;
|
||
this._directory = directory;
|
||
this._documents = documents;
|
||
this._effectiveScope = mode;
|
||
this._messages = [];
|
||
this._contextFiles = [];
|
||
this._adhocFiles = [];
|
||
this._adhocDirs = [];
|
||
this._images = [];
|
||
this._activeSkill = null;
|
||
this._activeDeepResearch = false;
|
||
this._isLoading = true;
|
||
await this._loadHistory(preferredSessionId);
|
||
|
||
if (!this._panel) {
|
||
this._panel = this._render();
|
||
document.body.appendChild(this._panel);
|
||
this._applyPanelWidth();
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
}
|
||
|
||
// An explicit open always reveals the panel, even if it was collapsed
|
||
// in a previous session (previously it stayed hidden → "ne s'ouvre plus").
|
||
this._setHidden(false);
|
||
this._updateHeader();
|
||
this._updateStatus();
|
||
this._renderMessages({ anchor: true });
|
||
this._showSuggestions();
|
||
this._renderAttachments();
|
||
this._closeHistoryMenu();
|
||
this._renderHistoryMenu();
|
||
|
||
requestAnimationFrame(() => {
|
||
this._panel.classList.add('open');
|
||
document.dispatchEvent(new CustomEvent('bookslm:opened', {
|
||
detail: { mode, vault, directory, documents },
|
||
}));
|
||
});
|
||
this._isOpen = true;
|
||
|
||
await this._loadContext();
|
||
this._isLoading = false;
|
||
// Warm the `@` path cache so the mention menu appears instantly.
|
||
this._ensurePaths().catch(() => { /* best effort */ });
|
||
}
|
||
|
||
/** Backwards-compatible helper: open the assistant on a directory. */
|
||
async open(vault, directory) {
|
||
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.
|
||
*/
|
||
async openForCurrentContext() {
|
||
const docs = collectOpenDocuments();
|
||
if (docs.length) {
|
||
const vault = docs[0].vault;
|
||
const scoped = docs.filter((d) => d.vault === vault);
|
||
return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped });
|
||
}
|
||
return this.openContext({ mode: MODE.GENERAL });
|
||
}
|
||
|
||
close() {
|
||
if (!this._panel || !this._isOpen) return;
|
||
this._panel.classList.remove('open');
|
||
this._isOpen = false;
|
||
document.dispatchEvent(new CustomEvent('bookslm:closed'));
|
||
if (this._abortCtrl) {
|
||
this._abortCtrl.abort();
|
||
this._abortCtrl = null;
|
||
}
|
||
}
|
||
|
||
_setHidden(hidden) {
|
||
if (!this._panel) return;
|
||
this._panel.classList.toggle('hidden', hidden);
|
||
try {
|
||
localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false');
|
||
} catch { /* */ }
|
||
this._updateToggleIcon();
|
||
}
|
||
|
||
/** Collapse/expand while keeping the panel mounted (header toggle button). */
|
||
_toggleSidebar() {
|
||
if (!this._panel) return;
|
||
const isHidden = !this._panel.classList.contains('hidden');
|
||
this._setHidden(isHidden);
|
||
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
|
||
}
|
||
|
||
_updateToggleIcon() {
|
||
if (!this._panel) return;
|
||
const btn = this._panel.querySelector('.bookslm-btn-toggle');
|
||
if (!btn) return;
|
||
const isHidden = this._panel.classList.contains('hidden');
|
||
const icon = btn.querySelector('i[data-lucide]');
|
||
if (!icon) return;
|
||
icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close');
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
}
|
||
|
||
newConversation() {
|
||
// 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._dirtySessionIds.add(session.id);
|
||
this._persistSessions();
|
||
this._renderMessages();
|
||
this._showSuggestions();
|
||
this._renderHistoryMenu();
|
||
}
|
||
|
||
exportConversation() {
|
||
if (!this._messages.length) return;
|
||
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**';
|
||
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
|
||
}
|
||
const blob = new Blob([md], { type: 'text/markdown' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
openFile(path) {
|
||
return this._openFileLink(path);
|
||
}
|
||
|
||
_normalizeLinkPath(rawPath) {
|
||
return String(rawPath || '')
|
||
.normalize('NFC')
|
||
.trim()
|
||
.replace(/^\.\//, '')
|
||
.replace(/\\/g, '/')
|
||
.replace(/^\/+/, '')
|
||
.replace(/\/+$/, '')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Comparison key for a vault path: NFC-normalised and lowercased, so a model
|
||
* writing a decomposed accent (`e` + combining acute, as macOS stores it)
|
||
* still matches a precomposed index entry, and vice versa.
|
||
*/
|
||
_normKey(value) {
|
||
return String(value || '').normalize('NFC').toLowerCase();
|
||
}
|
||
|
||
/** Vault names the client knows about (sidebar list, current context). */
|
||
_knownVaultNames() {
|
||
const names = new Set();
|
||
const add = (entry) => {
|
||
const name = typeof entry === 'string' ? entry : (entry && entry.name);
|
||
if (name && name !== 'all') names.add(String(name));
|
||
};
|
||
try {
|
||
if (Array.isArray(state.allVaults)) state.allVaults.forEach(add);
|
||
} catch { /* not ready */ }
|
||
add(this._vault);
|
||
try { add(state.currentVault); } catch { /* not ready */ }
|
||
return names;
|
||
}
|
||
|
||
/**
|
||
* Split an optional leading vault name off a mentioned path, e.g.
|
||
* `TestVault/Recettes/Pizza Maison.md` → `{vault: 'TestVault', path: 'Recettes/Pizza Maison.md'}`.
|
||
* The prefix is only recognised when it matches a known vault name and is
|
||
* followed by at least one more segment.
|
||
*/
|
||
_splitVaultPrefix(rawPath) {
|
||
const clean = this._normalizeLinkPath(rawPath);
|
||
if (!clean) return { vault: null, path: clean };
|
||
const idx = clean.indexOf('/');
|
||
if (idx <= 0) return { vault: null, path: clean };
|
||
const head = clean.slice(0, idx);
|
||
const rest = clean.slice(idx + 1);
|
||
if (!rest) return { vault: null, path: clean };
|
||
for (const name of this._knownVaultNames()) {
|
||
if (this._normKey(name) === this._normKey(head)) return { vault: name, path: rest };
|
||
}
|
||
return { vault: null, path: clean };
|
||
}
|
||
|
||
/**
|
||
* Synchronous lookup of a (possibly space-containing or vault-prefixed) path
|
||
* in the cached vault path index. Used at render time to decide whether a bare
|
||
* mention is a real vault path before turning it into a link.
|
||
*/
|
||
_confirmPathInCache(rawPath) {
|
||
const paths = this._pathCache;
|
||
if (!Array.isArray(paths) || !paths.length) return null;
|
||
const { vault, path } = this._splitVaultPrefix(rawPath);
|
||
const active = this._activeVault();
|
||
if (vault && active && this._normKey(vault) !== this._normKey(active)) {
|
||
// Another vault cannot be validated synchronously: accept the relative
|
||
// path and let the click-time resolution fetch that vault's index.
|
||
return { path, vault };
|
||
}
|
||
const clean = this._normKey(path);
|
||
if (!clean) return null;
|
||
const hit = paths.find((p) => this._normKey(p.path) === clean)
|
||
|| paths.find((p) => this._normKey(p.path).endsWith('/' + clean));
|
||
return hit ? { ...hit, vault: vault || active || null } : null;
|
||
}
|
||
|
||
/**
|
||
* Linkify bare path mentions that contain spaces (`Mon dossier/Ma note.md`).
|
||
*
|
||
* A candidate is accepted only when the longest word-aligned suffix exists in
|
||
* the vault path index. That keeps ordinary prose unturned-into-links and
|
||
* prevents leading sentence words ("Ouvre Mon dossier/…") from being swallowed
|
||
* into the link. Returns the segment unchanged when no path index is available.
|
||
*/
|
||
_linkifySpacePaths(segment, put) {
|
||
const paths = this._pathCache;
|
||
if (!Array.isArray(paths) || !paths.length) return segment;
|
||
// Fresh regex instance: PATH_WITH_SPACES_RE is module-level and its
|
||
// lastIndex must not leak between (possibly nested) render calls.
|
||
const re = new RegExp(PATH_WITH_SPACES_RE.source, 'g');
|
||
let out = '';
|
||
let last = 0;
|
||
let m;
|
||
while ((m = re.exec(segment)) !== null) {
|
||
const pre = m[1] || '';
|
||
const cand = m[2];
|
||
const candStart = m.index + pre.length;
|
||
if (candStart < last) { re.lastIndex = candStart + 1; continue; }
|
||
const accepted = this._acceptSpaceCandidate(this._decodeEntities(cand));
|
||
if (!accepted) {
|
||
// Rejected: retry from the next character so an over-long candidate
|
||
// (leading prose word) does not mask the real path inside it.
|
||
re.lastIndex = candStart + 1;
|
||
continue;
|
||
}
|
||
out += segment.slice(last, candStart + accepted.offset);
|
||
// The accepted text (vault prefix and accents included) is both the link
|
||
// label and the path to resolve at click time.
|
||
out += put(this._pathLink(accepted.display, accepted.display));
|
||
last = candStart + cand.length;
|
||
re.lastIndex = last;
|
||
}
|
||
return out ? out + segment.slice(last) : segment;
|
||
}
|
||
|
||
/** Longest word-aligned suffix of `cand` that exists in the path index. */
|
||
_acceptSpaceCandidate(cand) {
|
||
const wordRe = /\S+/g;
|
||
let w;
|
||
while ((w = wordRe.exec(cand)) !== null) {
|
||
const sub = cand.slice(w.index);
|
||
if (!sub.includes('/')) continue;
|
||
if (this._confirmPathInCache(sub)) return { display: sub, offset: w.index };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a mentioned path against a vault's indexed paths so links keep
|
||
* working even when the model writes a slightly different path — leading
|
||
* slash or backslashes, a bare basename, a leading vault name
|
||
* (`TestVault/Recettes/Pizza.md`) or a differently-normalised accent.
|
||
*
|
||
* @param {string} rawPath Mentioned path.
|
||
* @param {string|null} [vaultOverride] Vault to look into when the mention
|
||
* carries no `Vault/…` prefix.
|
||
* @returns {Promise<{path: string, name?: string, type?: string, vault: string|null}|null>}
|
||
*/
|
||
async _resolveExistingPath(rawPath, vaultOverride) {
|
||
const split = this._splitVaultPrefix(rawPath);
|
||
const vault = split.vault || vaultOverride || this._activeVault();
|
||
const clean = this._normKey(split.path);
|
||
if (!clean) return null;
|
||
const paths = await this._fetchPathsForVault(vault);
|
||
if (!Array.isArray(paths) || !paths.length) return null;
|
||
|
||
const found = this._matchIndexedPath(paths, clean);
|
||
if (found) return { ...found, vault };
|
||
|
||
// The leading segment may be a vault name the client does not know about
|
||
// (or an extra directory the index does not carry): retry one segment down.
|
||
const slash = split.path.indexOf('/');
|
||
if (slash > 0) {
|
||
const reduced = this._normKey(split.path.slice(slash + 1));
|
||
if (reduced) {
|
||
const fallback = this._matchIndexedPath(paths, reduced);
|
||
if (fallback) return { ...fallback, vault };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Match a normalised path against the index (exact → suffix → basename). */
|
||
_matchIndexedPath(paths, clean) {
|
||
const exact = paths.find((p) => this._normKey(p.path) === clean);
|
||
if (exact) return exact;
|
||
|
||
// Suffix match: the model may omit a leading directory segment.
|
||
const suffix = paths.find((p) => this._normKey(p.path).endsWith('/' + clean));
|
||
if (suffix) return suffix;
|
||
|
||
// Unique basename match (prefer files over directories).
|
||
const base = clean.split('/').pop();
|
||
const matches = paths.filter((p) => this._normKey(p.name || String(p.path || '').split('/').pop()) === base);
|
||
if (matches.length === 1) return matches[0];
|
||
if (matches.length > 1) {
|
||
const file = matches.find((p) => p.type === 'file');
|
||
if (file) return file;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Path index for a specific vault. Uses the cached list when it is the one
|
||
* already loaded (the `@` menu / render-time checks) and otherwise performs a
|
||
* one-off fetch without clobbering that cache.
|
||
*/
|
||
async _fetchPathsForVault(vault) {
|
||
if (!vault) return [];
|
||
if (Array.isArray(this._pathCache) && this._pathCacheVault === vault) return this._pathCache;
|
||
try {
|
||
const data = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`);
|
||
return Array.isArray(data.results) ? data.results : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Copy a mentioned filename to the clipboard (bare-name links). */
|
||
_copyLinkName(path) {
|
||
const name = this._normalizeLinkPath(path).split('/').pop();
|
||
if (!name) return;
|
||
const done = () => showToast(t('bookslm.copied_name', { name }), 'success');
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(name).then(done).catch(() => this._fallbackCopy(name, done));
|
||
return;
|
||
}
|
||
} catch { /* fall through */ }
|
||
this._fallbackCopy(name, done);
|
||
}
|
||
|
||
_fallbackCopy(text, done) {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if (typeof done === 'function') done();
|
||
} catch { /* clipboard unavailable */ }
|
||
}
|
||
|
||
/**
|
||
* Route a clicked path link: open the file in the viewer, reveal a directory
|
||
* in the tree, or (when the path cannot be resolved) copy the filename so the
|
||
* user still gets something useful instead of "File not found".
|
||
*/
|
||
async _activatePath(rawPath, kind) {
|
||
const split = this._splitVaultPrefix(rawPath);
|
||
const vault = split.vault || this._activeVault();
|
||
if (!vault) {
|
||
showToast(t('bookslm.no_vault'), 'error');
|
||
return;
|
||
}
|
||
const resolved = await this._resolveExistingPath(rawPath);
|
||
if (resolved) {
|
||
const targetVault = resolved.vault || vault;
|
||
if (resolved.type === 'directory') return this._revealPath(resolved.path, targetVault);
|
||
return this._openFileLink(resolved.path, targetVault);
|
||
}
|
||
if (kind === 'dir') {
|
||
// Unknown directory: still try to reveal it in the tree (best effort).
|
||
return this._revealPath(split.path, vault);
|
||
}
|
||
// Unknown file: copy the name rather than opening a dead link.
|
||
this._copyLinkName(rawPath);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* Vault used for context/chat requests. Falls back to the vault of the first
|
||
* ad-hoc `@` entry: in General mode there is no base vault, but the selected
|
||
* files/directories still belong to a vault that must be sent to the server.
|
||
*/
|
||
_contextVault() {
|
||
if (this._vault) return this._vault;
|
||
const entry = this._adhocFiles[0] || this._adhocDirs[0] || this._images[0];
|
||
if (entry && entry.vault) return entry.vault;
|
||
return this._resolveVault();
|
||
}
|
||
|
||
/**
|
||
* Concrete vault used by the assistant when none is explicitly resolved:
|
||
* falls back to the sidebar's selected context vault, then the first
|
||
* available vault. Used to browse `@` files and to open/reveal links.
|
||
*/
|
||
_activeVault() {
|
||
const resolved = this._contextVault();
|
||
if (resolved) return resolved;
|
||
const selected = state.selectedContextVault;
|
||
if (selected && selected !== 'all') return selected;
|
||
const first = Array.isArray(state.allVaults) ? state.allVaults[0] : null;
|
||
return (first && (first.name || first)) || null;
|
||
}
|
||
|
||
/**
|
||
* Fetch (once per vault) the flat list of indexed paths so the `@` menu can
|
||
* filter files/directories instantly on the client. Falls back to an empty
|
||
* list on failure; callers then use the tree-search endpoint.
|
||
*/
|
||
async _ensurePaths() {
|
||
const vault = this._activeVault();
|
||
if (!vault) return [];
|
||
if (this._pathCache && this._pathCacheVault === vault) return this._pathCache;
|
||
if (this._pathCachePromise && this._pathCacheVault === vault) return this._pathCachePromise;
|
||
this._pathCacheVault = vault;
|
||
this._pathCachePromise = api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`)
|
||
.then((data) => {
|
||
const paths = Array.isArray(data.results) ? data.results : [];
|
||
this._pathCache = paths;
|
||
return paths;
|
||
})
|
||
.catch(() => {
|
||
this._pathCache = [];
|
||
return [];
|
||
})
|
||
.finally(() => { this._pathCachePromise = null; });
|
||
return this._pathCachePromise;
|
||
}
|
||
|
||
/** Open a file mentioned in an assistant answer in the main viewer. */
|
||
async _openFileLink(path, vaultOverride) {
|
||
const vault = vaultOverride || this._activeVault();
|
||
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, vaultOverride) {
|
||
const vault = vaultOverride || this._activeVault();
|
||
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 ─────────────────────────────────────────────────────────
|
||
|
||
async _loadContext() {
|
||
const hasAdhoc = this._adhocFiles.length > 0 || this._adhocDirs.length > 0;
|
||
if (this._mode === MODE.GENERAL && !hasAdhoc) {
|
||
this._effectiveScope = MODE.GENERAL;
|
||
this._contextFiles = [];
|
||
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
|
||
this._showSuggestions();
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
mode: this._mode,
|
||
vault: this._contextVault(),
|
||
directory: this._directory,
|
||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||
extra_files: this._adhocFiles.map((f) => f.path),
|
||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||
};
|
||
try {
|
||
const data = await api('/api/ai/bookslm/context', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
this._contextFiles = data.files || [];
|
||
this._effectiveScope = data.scope || this._mode;
|
||
this._updateStatus(data);
|
||
this._showSuggestions();
|
||
} catch (e) {
|
||
console.warn('AI assistant: context load failed', e);
|
||
this._contextFiles = [];
|
||
this._updateStatus({ error: e.message });
|
||
}
|
||
}
|
||
|
||
_contextMeta() {
|
||
if (this._mode === MODE.DOCUMENTS) {
|
||
const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean);
|
||
return {
|
||
icon: 'file-text',
|
||
title: t('ai.context_documents', { count: this._documents.length }),
|
||
subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''),
|
||
};
|
||
}
|
||
if (this._mode === MODE.DIRECTORY) {
|
||
const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault;
|
||
return {
|
||
icon: 'folder',
|
||
title: name || this._vault || t('bookslm.title'),
|
||
subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`,
|
||
};
|
||
}
|
||
return {
|
||
icon: 'sparkles',
|
||
title: t('ai.context_general'),
|
||
// The description is kept for the tooltip only, to keep the header clean.
|
||
subtitle: '',
|
||
tooltip: t('ai.context_general_desc'),
|
||
};
|
||
}
|
||
|
||
_contextTitle() {
|
||
return this._contextMeta().title;
|
||
}
|
||
|
||
/**
|
||
* Collect the live application state sent to the General assistant so it
|
||
* knows what the user is looking at: open documents, current viewer file,
|
||
* current directory/vault, active search and the files shown on the search
|
||
* results page. Best-effort — any failure yields a partial object.
|
||
*/
|
||
_buildAppContext() {
|
||
const ctx = {};
|
||
try {
|
||
if (this._vault) ctx.vault = this._vault;
|
||
else if (state.selectedContextVault && state.selectedContextVault !== 'all') {
|
||
ctx.vault = state.selectedContextVault;
|
||
}
|
||
if (this._directory) ctx.directory = this._directory;
|
||
if (state.currentVault) ctx.current_vault = state.currentVault;
|
||
if (state.currentPath) ctx.current_path = state.currentPath;
|
||
|
||
const docs = collectOpenDocuments();
|
||
if (docs.length) {
|
||
ctx.open_documents = docs.map((d) => ({ vault: d.vault, path: d.path }));
|
||
}
|
||
|
||
// #93 — Live edition session: the document currently replaced by the
|
||
// editor/Forge surface. Knowing it lets the assistant target "le document
|
||
// que je suis en train d'éditer" and update it in place.
|
||
const editing = this._editingDocument();
|
||
if (editing) ctx.editing = editing;
|
||
|
||
const query = state.advancedSearchLastQuery;
|
||
if (query) {
|
||
ctx.search_query = query;
|
||
if (typeof state.advancedSearchTotal === 'number') {
|
||
ctx.search_total = state.advancedSearchTotal;
|
||
}
|
||
}
|
||
|
||
// Files currently rendered on the search results page, if any.
|
||
const items = document.querySelectorAll('.search-result-item[data-path]');
|
||
if (items.length) {
|
||
ctx.search_results = Array.from(items).slice(0, 20).map((el) => ({
|
||
vault: el.getAttribute('data-vault'),
|
||
path: el.getAttribute('data-path'),
|
||
}));
|
||
}
|
||
} catch (e) {
|
||
console.warn('AI assistant: app context collection failed', e);
|
||
}
|
||
return ctx;
|
||
}
|
||
|
||
/**
|
||
* #93 — The document currently being edited, i.e. the one the editor/Forge
|
||
* surface replaced the read view with, or null. Sent as `app_context.editing`
|
||
* so the assistant can target "le document que je suis en train d'éditer".
|
||
*/
|
||
_editingDocument() {
|
||
try {
|
||
const modal = document.getElementById('editor-modal');
|
||
if (!modal || !modal.classList.contains('active')) return null;
|
||
const vault = state.editorVault;
|
||
const path = state.editorPath;
|
||
if (!vault || !path) return null;
|
||
return { vault, path, surface: document.getElementById('forge-iframe') ? 'forge' : 'editor' };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #93 — A write tool just modified a vault file. Notify the UI so the
|
||
* displayed document is reloaded from disk: otherwise the read view keeps the
|
||
* stale content, and an open editor buffer autosaves the old text back over
|
||
* the assistant's change (see utils.reloadExternalWrite).
|
||
*/
|
||
_notifyFileWritten(data) {
|
||
if (!data || data.ok === false) return;
|
||
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
|
||
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
|
||
const args = data.arguments || {};
|
||
if (!args.vault || !args.path) return;
|
||
window.dispatchEvent(
|
||
new CustomEvent('obsigate:file-written', {
|
||
detail: { vault: args.vault, path: args.path, tool: data.name },
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ── Rendering ───────────────────────────────────────────────────────
|
||
|
||
_render() {
|
||
const panel = document.createElement('div');
|
||
panel.className = 'bookslm-panel';
|
||
panel.innerHTML = `
|
||
<div class="bookslm-resize-handle" role="separator" aria-orientation="vertical" title="${t('ai.resize_panel')}" aria-label="${t('ai.resize_panel')}"></div>
|
||
<div class="bookslm-header">
|
||
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
||
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
||
</button>
|
||
<span class="bookslm-context-icon"><i data-lucide="sparkles" style="width:16px;height:16px"></i></span>
|
||
<div class="bookslm-header-titles">
|
||
<span class="bookslm-title"></span>
|
||
<span class="bookslm-subtitle"></span>
|
||
</div>
|
||
<div class="bookslm-header-actions">
|
||
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
|
||
<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-picker-host ai-picker-slot"></span>
|
||
</div>
|
||
<div class="bookslm-status"></div>
|
||
<div class="bookslm-suggestions"></div>
|
||
<div class="bookslm-messages"></div>
|
||
<div class="bookslm-attachments"></div>
|
||
<div class="bookslm-menu-layer">
|
||
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
|
||
<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-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')}">
|
||
<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
|
||
// actions stay visible regardless of the provider/model text width.
|
||
const pickerHost = panel.querySelector('.bookslm-picker-host');
|
||
buildAIPickerUI().then((picker) => {
|
||
if (!picker || !pickerHost) return;
|
||
// Mount inside the host (kept as an `ai-picker-slot`) so a provider
|
||
// change in the config can rebuild the picker in place.
|
||
pickerHost.replaceChildren(picker);
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
}).catch(() => { /* ignore */ });
|
||
|
||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
|
||
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, reveals a directory in the sidebar tree, or (for a
|
||
// bare filename with no directory) copies the name to the clipboard.
|
||
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;
|
||
const kind = link.getAttribute('data-kind');
|
||
if (kind === 'name') {
|
||
this._copyLinkName(path);
|
||
return;
|
||
}
|
||
this._activatePath(path, kind);
|
||
});
|
||
|
||
// A manual scroll (wheel, touch or scrollbar drag) releases the
|
||
// top-pinning of the latest question.
|
||
const messagesEl = panel.querySelector('.bookslm-messages');
|
||
if (messagesEl) {
|
||
const unpin = () => {
|
||
if (!this._pinnedTurn) return;
|
||
this._pinnedTurn = false;
|
||
// The anchor padding is only there to make the post reachable while
|
||
// it is pinned — drop it so the reader gets no blank tail.
|
||
messagesEl.style.paddingBottom = '';
|
||
};
|
||
messagesEl.addEventListener('wheel', unpin, { passive: true });
|
||
messagesEl.addEventListener('touchmove', unpin, { passive: true });
|
||
messagesEl.addEventListener('mousedown', unpin, { passive: true });
|
||
}
|
||
|
||
// Close the session / command menus 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();
|
||
}
|
||
if (!e.target.closest('.bookslm-command-menu') && !e.target.closest('.bookslm-mention-menu')) {
|
||
this._hideMenus();
|
||
}
|
||
});
|
||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||
this._isFullscreen = !this._isFullscreen;
|
||
panel.classList.toggle('fullscreen', this._isFullscreen);
|
||
const icon = panel.querySelector('.bookslm-btn-fullscreen i');
|
||
if (icon) {
|
||
icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2');
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
}
|
||
});
|
||
|
||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||
|
||
// Resizable panel (drag the left edge).
|
||
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
|
||
if (resizeHandle) resizeHandle.addEventListener('mousedown', (e) => this._startResize(e));
|
||
|
||
// #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');
|
||
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', () => {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||
this._onComposerInput();
|
||
});
|
||
textarea.addEventListener('paste', (e) => this._onPaste(e));
|
||
|
||
// Menu navigation is handled at the panel level (capture phase) so the
|
||
// arrow keys keep working even if focus is not exactly on the textarea
|
||
// (e.g. after interacting with the menu). It runs before the textarea
|
||
// handler and stops propagation when it consumes the event.
|
||
panel.addEventListener('keydown', (e) => {
|
||
if (!this._menuKind) return;
|
||
const isComposer = e.target && e.target.tagName === 'TEXTAREA';
|
||
const isNav = e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape';
|
||
const isPick = e.key === 'Enter' && isComposer && !e.ctrlKey && !e.metaKey;
|
||
if ((isNav || isPick) && this._handleMenuKey(e)) {
|
||
e.stopPropagation();
|
||
}
|
||
}, true);
|
||
|
||
textarea.addEventListener('keydown', (e) => {
|
||
// Menu navigation (mention/command) takes precedence over submit.
|
||
if (this._handleMenuKey(e)) return;
|
||
if (e.key !== 'Enter') return;
|
||
if (e.ctrlKey || e.metaKey) {
|
||
// Ctrl/Cmd+Enter → insert a newline at the caret.
|
||
e.preventDefault();
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end);
|
||
textarea.selectionStart = textarea.selectionEnd = start + 1;
|
||
textarea.dispatchEvent(new Event('input'));
|
||
return;
|
||
}
|
||
// Enter (with or without Shift) → submit. Shift+Enter also submits so
|
||
// the documented shortcut (Ctrl+Enter) is the only way to add a line.
|
||
e.preventDefault();
|
||
this._sendMessage();
|
||
});
|
||
|
||
// Setup Drag & Drop for internal items and external files (#89)
|
||
this._setupDropZones(panel);
|
||
|
||
// Save singleton reference on window
|
||
window.BooksLMInstance = this;
|
||
|
||
return panel;
|
||
}
|
||
|
||
// ── Panel resize ────────────────────────────────────────────────────
|
||
|
||
_applyPanelWidth() {
|
||
if (!this._panel) return;
|
||
let width = 0;
|
||
try { width = parseInt(localStorage.getItem(PANEL_WIDTH_KEY) || '', 10) || 0; } catch { /* */ }
|
||
if (width) this._panel.style.width = `${this._clampPanelWidth(width)}px`;
|
||
}
|
||
|
||
_clampPanelWidth(width) {
|
||
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
|
||
}
|
||
|
||
_setupDropZones(panel) {
|
||
const composer = panel.querySelector('.bookslm-composer') || panel;
|
||
|
||
const onDragOver = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
composer.classList.add('bookslm-drop-over');
|
||
};
|
||
|
||
const onDragLeave = (e) => {
|
||
if (composer.contains(e.relatedTarget)) return;
|
||
composer.classList.remove('bookslm-drop-over');
|
||
};
|
||
|
||
const onDrop = async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
composer.classList.remove('bookslm-drop-over');
|
||
|
||
// Check if internal tree drag
|
||
const treeData = e.dataTransfer.getData('tree:path') || e.dataTransfer.getData('text/plain');
|
||
const treeVault = e.dataTransfer.getData('tree:vault');
|
||
const treeType = e.dataTransfer.getData('tree:type');
|
||
|
||
if (treeData && treeVault) {
|
||
if (treeType === 'directory') {
|
||
this._addAdhocDir(treeData, treeVault);
|
||
} else {
|
||
this._addAdhocFile(treeData, treeVault);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// External files
|
||
const files = Array.from(e.dataTransfer.files || []);
|
||
if (files.length > 0) {
|
||
this.handleExternalDroppedItems(files.map((f) => ({ path: f.name, file: f, isDir: false })));
|
||
}
|
||
};
|
||
|
||
composer.addEventListener('dragover', onDragOver);
|
||
composer.addEventListener('dragleave', onDragLeave);
|
||
composer.addEventListener('drop', onDrop);
|
||
}
|
||
|
||
/**
|
||
* Handle files/folders dropped from outside into BooksLM (#89).
|
||
*/
|
||
async handleExternalDroppedItems(items) {
|
||
if (!this._isOpen) {
|
||
this.open();
|
||
}
|
||
|
||
for (const item of items) {
|
||
if (item.isDir) {
|
||
continue;
|
||
}
|
||
const file = item.file;
|
||
if (!file) continue;
|
||
|
||
if (file.type && file.type.startsWith('image/')) {
|
||
this._addImageFile(file);
|
||
} else {
|
||
// Read text/markdown file content
|
||
try {
|
||
const text = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ''));
|
||
reader.onerror = reject;
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
// Insert into context files or append to composer
|
||
this._adhocFiles.push({
|
||
path: item.path,
|
||
vault: null,
|
||
inlineContent: text,
|
||
});
|
||
this._renderAttachments();
|
||
} catch (err) {
|
||
console.warn('Could not read dropped file as text:', err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_startResize(e) {
|
||
if (!this._panel || this._isFullscreen) return;
|
||
e.preventDefault();
|
||
const startX = e.clientX;
|
||
const startWidth = this._panel.getBoundingClientRect().width;
|
||
const onMove = (ev) => {
|
||
// Panel is anchored to the right edge: dragging left widens it.
|
||
const next = this._clampPanelWidth(startWidth + (startX - ev.clientX));
|
||
this._panel.style.width = `${next}px`;
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
document.body.classList.remove('bookslm-resizing');
|
||
try {
|
||
localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(this._panel.getBoundingClientRect().width)));
|
||
} catch { /* */ }
|
||
};
|
||
document.body.classList.add('bookslm-resizing');
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// ── Attachments: ad-hoc context chips & images ──────────────────────
|
||
|
||
_renderAttachments() {
|
||
if (!this._panel) return;
|
||
const host = this._panel.querySelector('.bookslm-attachments');
|
||
if (!host) return;
|
||
host.innerHTML = '';
|
||
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill || this._activeDeepResearch;
|
||
host.classList.toggle('empty', !hasAny);
|
||
if (!hasAny) return;
|
||
|
||
if (this._activeSkill) {
|
||
const skill = this._skillById(this._activeSkill);
|
||
host.appendChild(this._chip(
|
||
`${skill ? skill.icon : '✨'} ${skill ? skill.label : this._activeSkill}`,
|
||
'skill',
|
||
() => { this._activeSkill = null; this._renderAttachments(); },
|
||
));
|
||
}
|
||
if (this._activeDeepResearch) {
|
||
host.appendChild(this._chip(
|
||
`🔎 ${t('bookslm.ext_deep_research')}`,
|
||
'deep-research',
|
||
() => { this._activeDeepResearch = false; this._renderAttachments(); },
|
||
));
|
||
}
|
||
for (const file of this._adhocFiles) {
|
||
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
|
||
}
|
||
for (const dir of this._adhocDirs) {
|
||
host.appendChild(this._chip(`📁 ${dir.path}`, 'dir', () => this._removeAdhocDir(dir.path)));
|
||
}
|
||
this._images.forEach((img, index) => {
|
||
const chip = this._chip(`🖼 ${img.name || t('ai.image')}`, 'image', () => {
|
||
this._images.splice(index, 1);
|
||
this._renderAttachments();
|
||
});
|
||
if (img.previewUrl) {
|
||
const thumb = document.createElement('img');
|
||
thumb.className = 'bookslm-chip-thumb';
|
||
thumb.src = img.previewUrl;
|
||
chip.insertBefore(thumb, chip.firstChild);
|
||
}
|
||
host.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
_chip(label, kind, onRemove) {
|
||
const chip = document.createElement('span');
|
||
chip.className = `bookslm-chip bookslm-chip-${kind}`;
|
||
const text = document.createElement('span');
|
||
text.className = 'bookslm-chip-label';
|
||
text.textContent = label;
|
||
text.title = label;
|
||
chip.appendChild(text);
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'bookslm-chip-remove';
|
||
close.setAttribute('aria-label', t('ai.remove'));
|
||
close.textContent = '×';
|
||
close.addEventListener('click', onRemove);
|
||
chip.appendChild(close);
|
||
return chip;
|
||
}
|
||
|
||
_addAdhocFile(path, vault) {
|
||
if (!path || this._adhocFiles.some((f) => f.path === path)) return;
|
||
this._adhocFiles.push({ path, vault: vault || null });
|
||
this._renderAttachments();
|
||
this._loadContext();
|
||
}
|
||
|
||
_addAdhocDir(path, vault) {
|
||
const clean = path.replace(/\/+$/, '');
|
||
if (!clean || this._adhocDirs.some((d) => d.path === clean)) return;
|
||
this._adhocDirs.push({ path: clean, vault: vault || null });
|
||
this._renderAttachments();
|
||
this._loadContext();
|
||
}
|
||
|
||
_removeAdhocFile(path) {
|
||
this._adhocFiles = this._adhocFiles.filter((f) => f.path !== path);
|
||
this._renderAttachments();
|
||
this._loadContext();
|
||
}
|
||
|
||
_removeAdhocDir(path) {
|
||
this._adhocDirs = this._adhocDirs.filter((d) => d.path !== path);
|
||
this._renderAttachments();
|
||
this._loadContext();
|
||
}
|
||
|
||
_addImageFile(file) {
|
||
if (!file || !file.type || !file.type.startsWith('image/')) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = String(reader.result || '');
|
||
const match = result.match(/^data:([^;,]+);base64,(.*)$/);
|
||
if (!match) return;
|
||
this._images.push({
|
||
name: file.name || t('ai.image'),
|
||
mime_type: match[1],
|
||
data: match[2],
|
||
previewUrl: result,
|
||
});
|
||
this._renderAttachments();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
_addImageFiles(files) {
|
||
if (!files) return;
|
||
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', (e) => {
|
||
// BUG-048 — Keep the event from reaching the panel's click handler,
|
||
// which would hide a mention/command menu opened by this action right
|
||
// after it opens (the menu is rendered asynchronously).
|
||
e.stopPropagation();
|
||
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/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
|
||
_startDeepResearch() {
|
||
this._closeExtMenu();
|
||
if (!this._agentMode) this._toggleAgentMode();
|
||
this._activeDeepResearch = true;
|
||
this._renderAttachments();
|
||
showToast(t('bookslm.deep_research_started'), 'info');
|
||
}
|
||
|
||
_onPaste(e) {
|
||
const items = e.clipboardData && e.clipboardData.items;
|
||
if (!items) return;
|
||
const images = [];
|
||
for (const item of items) {
|
||
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
||
const file = item.getAsFile();
|
||
if (file) images.push(file);
|
||
}
|
||
}
|
||
if (images.length) {
|
||
e.preventDefault();
|
||
images.forEach((file) => this._addImageFile(file));
|
||
}
|
||
}
|
||
|
||
// ── Composer: `/` commands and `@` mentions ─────────────────────────
|
||
|
||
_onComposerInput() {
|
||
if (!this._panel) return;
|
||
const textarea = this._panel.querySelector('textarea');
|
||
if (!textarea) return;
|
||
const pos = textarea.selectionStart;
|
||
const before = textarea.value.slice(0, pos);
|
||
// Unicode-aware so accented skill names / paths keep the menu open.
|
||
const command = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]*)$/u);
|
||
if (command) {
|
||
this._showCommandMenu(command[1]);
|
||
return;
|
||
}
|
||
const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u);
|
||
if (mention) {
|
||
this._showMentionMenu(mention[1]);
|
||
return;
|
||
}
|
||
this._hideMenus();
|
||
}
|
||
|
||
async _ensureSkills() {
|
||
if (this._skills.length) return this._skills;
|
||
try {
|
||
const data = await api('/api/ai/skills');
|
||
this._skills = [...(data.skills || []), ...(data.commands || [])];
|
||
} catch (e) {
|
||
console.warn('AI assistant: skills load failed', e);
|
||
this._skills = [];
|
||
}
|
||
return this._skills;
|
||
}
|
||
|
||
_skillById(id) {
|
||
return this._skills.find((s) => s.id === id) || null;
|
||
}
|
||
|
||
async _showCommandMenu(query) {
|
||
if (!this._panel) return;
|
||
const seq = ++this._menuSeq;
|
||
const skills = await this._ensureSkills();
|
||
if (seq !== this._menuSeq) return; // a newer input superseded this render
|
||
const q = normalizeSearch(query);
|
||
const matches = skills.filter((s) =>
|
||
!q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q));
|
||
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
|
||
}
|
||
|
||
_selectCommand(skill) {
|
||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||
this._hideMenus();
|
||
if (skill.type === 'admin') {
|
||
this._runAdminCommand(skill.id);
|
||
if (textarea) {
|
||
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
|
||
}
|
||
return;
|
||
}
|
||
if (skill.special === 'create_skill') {
|
||
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
|
||
this._promptCreateSkill();
|
||
return;
|
||
}
|
||
this._activeSkill = skill.id;
|
||
if (textarea) {
|
||
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
|
||
textarea.focus();
|
||
}
|
||
this._renderAttachments();
|
||
}
|
||
|
||
async _showMentionMenu(query) {
|
||
if (!this._panel) return;
|
||
const seq = ++this._menuSeq;
|
||
const vault = this._activeVault();
|
||
const q = normalizeSearch(query);
|
||
try {
|
||
let results = [];
|
||
// Fast path: filter the prefetched flat path list on the client so the
|
||
// menu updates instantly while typing.
|
||
const cached = await this._ensurePaths();
|
||
if (seq !== this._menuSeq) return;
|
||
if (cached.length) {
|
||
const matches = q
|
||
? cached.filter((p) => normalizeSearch(p.path).includes(q))
|
||
: cached;
|
||
results = matches.slice(0, 20);
|
||
} else if (query) {
|
||
const searchVault = vault || 'all';
|
||
const data = await api(
|
||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
|
||
);
|
||
results = (data.results || []).slice(0, 20);
|
||
} else if (vault) {
|
||
const data = await api(
|
||
`/api/vault/${encodeURIComponent(vault)}/files?limit=20&recursive=true`
|
||
);
|
||
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file', vault }));
|
||
}
|
||
if (seq !== this._menuSeq) return; // superseded while the request ran
|
||
const items = results.map((r) => {
|
||
const path = r.path || r;
|
||
const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file');
|
||
const type = rawType === 'directory' ? 'dir' : rawType;
|
||
return { id: path, label: path, type, vault: r.vault || vault || null };
|
||
});
|
||
// Always render the menu (even empty) so typing `@` gives visible feedback.
|
||
this._renderMenu('mention', items, (item) => this._selectMention(item));
|
||
} catch (e) {
|
||
if (seq !== this._menuSeq) return;
|
||
console.warn('AI assistant: mention search failed', e);
|
||
this._renderMenu('mention', [], () => {});
|
||
}
|
||
}
|
||
|
||
_selectMention(item) {
|
||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||
this._hideMenus();
|
||
if (textarea) {
|
||
textarea.value = textarea.value.replace(/(^|\s)@[\p{L}\p{N}._/-]*$/u, '$1').trim();
|
||
textarea.focus();
|
||
}
|
||
const path = item.id;
|
||
const vault = item.vault || this._activeVault();
|
||
if (IMAGE_EXT_RE.test(path)) {
|
||
this._addImagePath(path, vault);
|
||
} else if (item.type === 'dir' || this._classifyPath(path) === 'dir') {
|
||
this._addAdhocDir(path, vault);
|
||
} else {
|
||
this._addAdhocFile(path, vault);
|
||
}
|
||
}
|
||
|
||
_addImagePath(path, vault) {
|
||
if (!path || this._images.some((img) => img.path === path)) return;
|
||
this._images.push({ path, name: path.split('/').pop(), vault: vault || null });
|
||
this._renderAttachments();
|
||
}
|
||
|
||
_renderMenu(kind, items, onPick) {
|
||
if (!this._panel) return;
|
||
const menu = this._panel.querySelector(`.bookslm-${kind}-menu`);
|
||
if (!menu) return;
|
||
menu.innerHTML = '';
|
||
this._menuKind = kind;
|
||
this._menuItems = items;
|
||
this._menuIndex = 0;
|
||
if (!items.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'bookslm-menu-empty';
|
||
empty.textContent = t('ai.no_results');
|
||
menu.appendChild(empty);
|
||
menu.classList.remove('hidden');
|
||
return;
|
||
}
|
||
items.forEach((item, index) => {
|
||
const el = document.createElement('button');
|
||
el.type = 'button';
|
||
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
|
||
el.dataset.index = String(index);
|
||
el.setAttribute('role', 'option');
|
||
el.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
|
||
const icon = document.createElement('span');
|
||
icon.className = 'bookslm-menu-icon';
|
||
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
|
||
const label = document.createElement('span');
|
||
label.className = 'bookslm-menu-label';
|
||
label.textContent = item.label || item.id;
|
||
el.appendChild(icon);
|
||
el.appendChild(label);
|
||
if (item.usage) {
|
||
const usage = document.createElement('span');
|
||
usage.className = 'bookslm-menu-usage';
|
||
usage.textContent = item.usage;
|
||
el.appendChild(usage);
|
||
} else if (item.description) {
|
||
const desc = document.createElement('span');
|
||
desc.className = 'bookslm-menu-desc';
|
||
desc.textContent = item.description;
|
||
el.appendChild(desc);
|
||
}
|
||
el.addEventListener('mouseenter', () => this._highlightMenu(index));
|
||
el.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
onPick(item);
|
||
});
|
||
menu.appendChild(el);
|
||
});
|
||
// Show the other menu hidden.
|
||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||
if (m !== menu) m.classList.add('hidden');
|
||
});
|
||
menu.classList.remove('hidden');
|
||
}
|
||
|
||
_highlightMenu(index) {
|
||
if (!this._panel || !this._menuKind) return;
|
||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||
if (!menu) return;
|
||
const options = menu.querySelectorAll('.bookslm-menu-item');
|
||
if (!options.length) return;
|
||
const next = ((index % options.length) + options.length) % options.length;
|
||
this._menuIndex = next;
|
||
options.forEach((el, i) => {
|
||
const on = i === next;
|
||
el.classList.toggle('active', on);
|
||
el.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
const active = options[next];
|
||
if (active && typeof active.scrollIntoView === 'function') {
|
||
active.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
_handleMenuKey(e) {
|
||
if (!this._panel || !this._menuKind) return false;
|
||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||
if (!menu || menu.classList.contains('hidden')) return false;
|
||
const items = this._menuItems;
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
this._hideMenus();
|
||
return true;
|
||
}
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (!items.length) return true;
|
||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||
const next = (this._menuIndex + delta + items.length) % items.length;
|
||
this._highlightMenu(next);
|
||
return true;
|
||
}
|
||
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||
e.preventDefault();
|
||
const item = items[this._menuIndex];
|
||
if (!item) return true;
|
||
const menuEl = menu.querySelector('.bookslm-menu-item.active');
|
||
if (menuEl) menuEl.click();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
_hideMenus() {
|
||
if (!this._panel) return;
|
||
// Invalidate any in-flight async menu render.
|
||
this._menuSeq++;
|
||
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
|
||
m.classList.add('hidden');
|
||
});
|
||
this._menuKind = null;
|
||
this._menuItems = [];
|
||
this._menuIndex = 0;
|
||
}
|
||
|
||
// ── Admin commands (`/help`, `/providers`, …) ───────────────────────
|
||
|
||
async _runAdminCommand(id) {
|
||
switch (id) {
|
||
case 'help': return this._cmdHelp();
|
||
case 'providers': return this._cmdProviders();
|
||
case 'keys': return this._cmdKeys();
|
||
case 'provider': return this._cmdSwitchProvider();
|
||
case 'model': return this._cmdSwitchModel();
|
||
default: return undefined;
|
||
}
|
||
}
|
||
|
||
_pushAssistantNote(markdown) {
|
||
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
|
||
this._renderMessages({ anchor: true });
|
||
this._saveHistory();
|
||
}
|
||
|
||
async _cmdHelp() {
|
||
const skills = await this._ensureSkills();
|
||
const lines = [`## ${t('ai.commands')}`, ''];
|
||
for (const s of skills) {
|
||
lines.push(`- ${s.icon || ''} \`/${s.id}\` — ${s.description || s.label}`);
|
||
}
|
||
this._pushAssistantNote(lines.join('\n'));
|
||
}
|
||
|
||
async _cmdProviders() {
|
||
let providers = {};
|
||
try {
|
||
const status = await api('/api/ai/status');
|
||
providers = status.providers || {};
|
||
} catch { /* */ }
|
||
const lines = [`## ${t('ai.providers')}`, ''];
|
||
const names = Object.keys(providers);
|
||
if (!names.length) lines.push(t('ai.none'));
|
||
for (const name of names) {
|
||
const info = providers[name] || {};
|
||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}** — ${info.model || '—'}`);
|
||
}
|
||
this._pushAssistantNote(lines.join('\n'));
|
||
}
|
||
|
||
async _cmdKeys() {
|
||
const lines = [`## ${t('ai.keys')}`, ''];
|
||
try {
|
||
const keys = await api('/api/config/ai-keys');
|
||
for (const [env, value] of Object.entries(keys)) {
|
||
lines.push(`- ${value ? '✅' : '❌'} **${env.replace('_API_KEY', '').toLowerCase()}**`);
|
||
}
|
||
} catch {
|
||
const status = await api('/api/ai/status').catch(() => ({ providers: {} }));
|
||
for (const [name, info] of Object.entries(status.providers || {})) {
|
||
lines.push(`- ${info.available ? '✅' : '❌'} **${name}**`);
|
||
}
|
||
}
|
||
this._pushAssistantNote(lines.join('\n'));
|
||
}
|
||
|
||
_cmdSwitchProvider() {
|
||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-select option'))
|
||
.map((o) => o.value).filter(Boolean);
|
||
const list = names.join(', ');
|
||
this._pushAssistantNote(
|
||
`### ${t('ai.provider')}\n\n${t('ai.usage_provider', { list })}\n\n\`/provider <nom>\``,
|
||
);
|
||
}
|
||
|
||
_cmdSwitchModel() {
|
||
const names = Array.from(this._panel.querySelectorAll('.ai-picker-model option'))
|
||
.map((o) => o.value).filter(Boolean);
|
||
const list = names.length ? names.join(', ') : '—';
|
||
this._pushAssistantNote(
|
||
`### ${t('ai.model')}\n\n${t('ai.usage_model', { list })}\n\n\`/model <nom>\``,
|
||
);
|
||
}
|
||
|
||
/** Apply a provider/model selection from an admin command text. */
|
||
_applyPickerSelection(provider, model) {
|
||
if (!this._panel) return;
|
||
const providerSelect = this._panel.querySelector('.ai-picker-select');
|
||
const modelSelect = this._panel.querySelector('.ai-picker-model');
|
||
let state = {};
|
||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||
if (provider && providerSelect) {
|
||
state.provider = provider;
|
||
state.model = model || null;
|
||
delete state.capabilities;
|
||
providerSelect.value = provider;
|
||
providerSelect.dispatchEvent(new Event('change'));
|
||
if (model) {
|
||
const applyModel = () => {
|
||
if (modelSelect && Array.from(modelSelect.options).some((o) => o.value === model)) {
|
||
modelSelect.value = model;
|
||
modelSelect.dispatchEvent(new Event('change'));
|
||
}
|
||
};
|
||
setTimeout(applyModel, 400);
|
||
}
|
||
return;
|
||
}
|
||
if (model && modelSelect) {
|
||
state.model = model;
|
||
delete state.capabilities;
|
||
modelSelect.value = model;
|
||
modelSelect.dispatchEvent(new Event('change'));
|
||
}
|
||
}
|
||
|
||
/** Handle `/provider X` and `/model X` typed directly (with an argument). */
|
||
_maybeRunAdminWithArg(text) {
|
||
const provider = text.match(/^\/provider\s+(\S+)\s*$/i);
|
||
if (provider) {
|
||
this._applyPickerSelection(provider[1], null);
|
||
this._pushAssistantNote(`✅ ${t('ai.provider_changed', { name: provider[1] })}`);
|
||
return true;
|
||
}
|
||
const model = text.match(/^\/model\s+(\S+)\s*$/i);
|
||
if (model) {
|
||
this._applyPickerSelection(null, model[1]);
|
||
this._pushAssistantNote(`✅ ${t('ai.model_changed', { name: model[1] })}`);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── Custom skill creation (`/create-new-skill`) ─────────────────────
|
||
|
||
_promptCreateSkill() {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'bookslm-modal-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="bookslm-modal" role="dialog" aria-modal="true" aria-label="${t('ai.create_skill')}">
|
||
<h3 class="bookslm-modal-title">${t('ai.create_skill')}</h3>
|
||
<label class="bookslm-modal-field">${t('ai.skill_id')}
|
||
<input class="bookslm-skill-id" placeholder="mon-skill">
|
||
</label>
|
||
<label class="bookslm-modal-field">${t('ai.skill_label')}
|
||
<input class="bookslm-skill-label" placeholder="${t('ai.skill_label')}">
|
||
</label>
|
||
<label class="bookslm-modal-field">${t('ai.skill_prompt')}
|
||
<textarea class="bookslm-skill-prompt" rows="4" placeholder="${t('ai.skill_prompt_placeholder')}"></textarea>
|
||
</label>
|
||
<div class="bookslm-modal-error"></div>
|
||
<div class="bookslm-modal-actions">
|
||
<button type="button" class="bookslm-modal-cancel">${t('button.cancel')}</button>
|
||
<button type="button" class="bookslm-modal-ok primary">${t('common.save')}</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(overlay);
|
||
const close = () => overlay.remove();
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
overlay.querySelector('.bookslm-modal-cancel').addEventListener('click', close);
|
||
overlay.querySelector('.bookslm-modal-ok').addEventListener('click', async () => {
|
||
const id = overlay.querySelector('.bookslm-skill-id').value.trim().toLowerCase();
|
||
const label = overlay.querySelector('.bookslm-skill-label').value.trim();
|
||
const prompt = overlay.querySelector('.bookslm-skill-prompt').value.trim();
|
||
const err = overlay.querySelector('.bookslm-modal-error');
|
||
try {
|
||
await api('/api/ai/skills', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id, label, prompt }),
|
||
});
|
||
this._skills = [];
|
||
close();
|
||
showToast(t('ai.skill_created', { name: label || id }), 'success');
|
||
} catch (e) {
|
||
err.textContent = e.message || String(e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Vision support ──────────────────────────────────────────────────
|
||
|
||
async _modelSupportsVision() {
|
||
let state = {};
|
||
try { state = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}'); } catch { /* */ }
|
||
const provider = state.provider || null;
|
||
const model = state.model || '';
|
||
if (!provider) return true; // provider default resolution is server-side
|
||
let caps = state.capabilities;
|
||
if (!caps) caps = await getModelCapabilities(provider, model);
|
||
return !caps || caps.vision !== false;
|
||
}
|
||
|
||
_updateHeader() {
|
||
if (!this._panel) return;
|
||
const meta = this._contextMeta();
|
||
const titles = this._panel.querySelector('.bookslm-header-titles');
|
||
const title = this._panel.querySelector('.bookslm-title');
|
||
const subtitle = this._panel.querySelector('.bookslm-subtitle');
|
||
const icon = this._panel.querySelector('.bookslm-context-icon i');
|
||
if (title) title.textContent = meta.title;
|
||
if (subtitle) subtitle.textContent = meta.subtitle || '';
|
||
if (titles) titles.title = meta.tooltip || meta.subtitle || meta.title || '';
|
||
if (icon) icon.setAttribute('data-lucide', meta.icon);
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
}
|
||
|
||
/**
|
||
* Record the assistant activity state.
|
||
*
|
||
* The dedicated loading bar above the composer was removed: in agent mode
|
||
* the animated indicator now lives in the steps header, and in plain chat
|
||
* the pending answer shows an inline indicator. Kept as the single entry
|
||
* point for the streaming code paths (state is informational only).
|
||
* @param {'idle'|'working'|'done'|'error'} kind
|
||
* @param {string} [text]
|
||
*/
|
||
_setActivity(kind, text) {
|
||
this._activityKind = kind;
|
||
this._activityText = text || '';
|
||
}
|
||
|
||
_updateStatus(data) {
|
||
if (!this._panel) return;
|
||
const status = this._panel.querySelector('.bookslm-status');
|
||
if (!status) return;
|
||
|
||
if (data && data.error) {
|
||
status.classList.remove('hidden');
|
||
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
|
||
return;
|
||
}
|
||
if (data && Array.isArray(data.files)) {
|
||
if (data.scope === MODE.GENERAL) {
|
||
// No document context: keep the header clean. The description is
|
||
// available as a tooltip on the header title.
|
||
status.classList.add('hidden');
|
||
status.innerHTML = '';
|
||
return;
|
||
}
|
||
status.classList.remove('hidden');
|
||
const count = data.files.length;
|
||
const chars = data.total_chars || 0;
|
||
const maxChars = data.max_total_chars || 200000;
|
||
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
|
||
const label = `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
|
||
status.innerHTML = `
|
||
<span class="bookslm-status-text">${label}</span>
|
||
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
||
`;
|
||
} else if (this._isLoading) {
|
||
status.classList.remove('hidden');
|
||
status.textContent = '⏳ …';
|
||
}
|
||
}
|
||
|
||
_suggestionsForContext() {
|
||
if (this._mode === MODE.GENERAL) {
|
||
return [
|
||
t('ai.suggestion_capabilities'),
|
||
t('ai.suggestion_search'),
|
||
t('ai.suggestion_create_file'),
|
||
];
|
||
}
|
||
if (this._mode === MODE.DOCUMENTS) {
|
||
return [
|
||
t('ai.suggestion_docs_summary'),
|
||
t('ai.suggestion_docs_keypoints'),
|
||
t('ai.suggestion_docs_contradictions'),
|
||
];
|
||
}
|
||
return [
|
||
t('bookslm.suggestion_summary'),
|
||
t('bookslm.suggestion_themes'),
|
||
t('bookslm.suggestion_contradictions'),
|
||
];
|
||
}
|
||
|
||
_showSuggestions() {
|
||
if (!this._panel) return;
|
||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||
if (!sugEl) return;
|
||
sugEl.innerHTML = '';
|
||
if (this._messages.length) return;
|
||
|
||
for (const s of this._suggestionsForContext()) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'bookslm-suggestion';
|
||
btn.textContent = s;
|
||
btn.addEventListener('click', () => {
|
||
const textarea = this._panel.querySelector('textarea');
|
||
if (textarea) {
|
||
textarea.value = s;
|
||
this._sendMessage();
|
||
}
|
||
});
|
||
sugEl.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
_renderMessages(opts = {}) {
|
||
if (!this._panel) return;
|
||
const container = this._panel.querySelector('.bookslm-messages');
|
||
if (!container) return;
|
||
const prevTop = container.scrollTop;
|
||
container.innerHTML = '';
|
||
// NOTE: the anchor's bottom padding must survive the re-render. Clearing
|
||
// it here (outside the synchronous scroll correction) let the browser
|
||
// paint one frame with the question pushed back down the window.
|
||
|
||
// Notion-style thread: chronological order, the user post anchored at the
|
||
// top of the visible area (never scroll-to-bottom — the answer streams
|
||
// below the question). Streaming re-renders preserve the scroll position
|
||
// so the reader is never jumped around.
|
||
for (const msg of this._messages) {
|
||
container.appendChild(this._chatBubble(msg));
|
||
}
|
||
|
||
// While the pin is active (question submitted, released by a manual
|
||
// scroll) every re-render — streaming frames *and* the final one — must
|
||
// put the question back at the top, otherwise the answer's own growth
|
||
// would push it back down the window.
|
||
if (opts.anchor || this._pinnedTurn) {
|
||
this._anchorLatestUser({ instant: opts.anchor ? !!opts.instant : true });
|
||
} else {
|
||
// Unpinned: drop the anchor padding so no blank gap lingers.
|
||
container.style.paddingBottom = '';
|
||
container.scrollTop = prevTop;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bring the newest user post flush to the top of the thread and keep it
|
||
* there while the answer streams in.
|
||
*
|
||
* Two things are needed for the post to *really* sit at the top:
|
||
* 1. no `scroll-margin-top` (a gap would leave the tail of the previous
|
||
* answer visible above the post);
|
||
* 2. a short answer cannot fill the viewport on its own, so the thread's
|
||
* bottom padding is grown by exactly the missing amount — otherwise the
|
||
* browser clamps the scroll and the post stops half-way down the window.
|
||
*/
|
||
_anchorLatestUser(opts = {}) {
|
||
if (!this._panel) return;
|
||
this._pinnedTurn = true;
|
||
const container = this._panel.querySelector('.bookslm-messages');
|
||
if (!container) return;
|
||
const users = container.querySelectorAll('.bookslm-msg.user');
|
||
const all = container.querySelectorAll('.bookslm-msg');
|
||
// Fallback (admin notes with no user post): anchor the newest message.
|
||
const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild;
|
||
if (!el) return;
|
||
const behavior = opts.instant ? 'auto' : 'smooth';
|
||
const apply = () => {
|
||
// Grow the bottom padding so the anchor position becomes reachable.
|
||
const base = parseFloat(getComputedStyle(container).paddingBottom) || 0;
|
||
container.style.paddingBottom = '';
|
||
const cRect = container.getBoundingClientRect();
|
||
const target = el.getBoundingClientRect().top - cRect.top + container.scrollTop;
|
||
const max = container.scrollHeight - container.clientHeight;
|
||
// Only when the thread really scrolls: a thread shorter than the window
|
||
// already shows the post at the top, extra padding would just leave a
|
||
// blank tail.
|
||
if (max > 0 && target > max) {
|
||
container.style.paddingBottom = `${Math.ceil(base + (target - max))}px`;
|
||
} else {
|
||
container.style.paddingBottom = '';
|
||
}
|
||
try {
|
||
el.scrollIntoView({ behavior, block: 'start' });
|
||
} catch {
|
||
container.scrollTop = target;
|
||
}
|
||
// Correction pass: `scrollIntoView` can land a few pixels off (scroll
|
||
// anchoring, sub-pixel padding, momentum scroll). Trust the measured
|
||
// geometry, not the request.
|
||
if (behavior === 'auto') {
|
||
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||
if (Math.abs(delta) > 1) container.scrollTop += delta;
|
||
}
|
||
};
|
||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply);
|
||
else apply();
|
||
}
|
||
|
||
/**
|
||
* Build a single chat entry (user or assistant), Notion-style:
|
||
* • user → right-aligned rounded grey bubble (max 80%),
|
||
* • assistant → full-width text with no background bubble, a discreet
|
||
* provider/model tag above, a collapsible “N steps” block for agent
|
||
* tool calls, and a hover-revealed action bar (Copier) below.
|
||
*/
|
||
_chatBubble(msg) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'bookslm-msg ' + msg.role;
|
||
|
||
if (msg.role === 'assistant') {
|
||
// Discreet provider/model tag above the answer. Uses the actual engine
|
||
// reported by the backend SSE stream, so it reflects the real model even
|
||
// on the provider-default selection.
|
||
if (msg.provider || msg.model) {
|
||
const metaLine = document.createElement('div');
|
||
metaLine.className = 'bookslm-msg-meta';
|
||
const label = [msg.provider, msg.model].filter(Boolean).join(' · ');
|
||
metaLine.textContent = label;
|
||
metaLine.title = label;
|
||
wrap.appendChild(metaLine);
|
||
}
|
||
// Notion-style: the tool calls collapse into a “N étapes” line above the
|
||
// answer instead of a visible list.
|
||
const running = this._isRunningMessage(msg);
|
||
if (msg.toolCalls && msg.toolCalls.length) {
|
||
wrap.appendChild(this._renderToolActivity(msg.toolCalls, msg, running));
|
||
}
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'bookslm-bubble assistant';
|
||
const { text, actions } = this._extractActions(msg.content || '');
|
||
bubble.innerHTML = this._renderMarkdown(text);
|
||
this._enhanceCodeBlocks(bubble);
|
||
// Plain chat has no steps block: the pending answer itself carries the
|
||
// animated indicator until the first token arrives.
|
||
if (running && !text && !actions.length) {
|
||
const pending = document.createElement('div');
|
||
pending.className = 'bookslm-pending';
|
||
pending.appendChild(this._dots());
|
||
bubble.appendChild(pending);
|
||
}
|
||
if (actions.length) {
|
||
const actionsDiv = document.createElement('div');
|
||
actionsDiv.className = 'bookslm-actions';
|
||
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
|
||
bubble.appendChild(actionsDiv);
|
||
}
|
||
if (msg.confirmation) {
|
||
bubble.appendChild(this._renderConfirmationCard(msg));
|
||
}
|
||
if (msg.sources && msg.sources.length) {
|
||
const sourcesDiv = document.createElement('div');
|
||
sourcesDiv.className = 'bookslm-sources';
|
||
for (const src of msg.sources) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'bookslm-source-badge';
|
||
badge.textContent = `📄 ${src.split('/').pop()}`;
|
||
badge.title = src;
|
||
badge.addEventListener('click', () => this.openFile(src));
|
||
sourcesDiv.appendChild(badge);
|
||
}
|
||
bubble.appendChild(sourcesDiv);
|
||
}
|
||
wrap.appendChild(bubble);
|
||
} else {
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'bookslm-bubble user';
|
||
bubble.textContent = msg.content;
|
||
wrap.appendChild(bubble);
|
||
}
|
||
|
||
// Notion-style action bar revealed on hover of the message block.
|
||
this._appendActionBar(wrap, msg);
|
||
|
||
return wrap;
|
||
}
|
||
|
||
/** Hover action bar under a message: Copy (both roles) + Add (assistant). */
|
||
_appendActionBar(wrap, msg) {
|
||
const content = String(msg.content || '').trim();
|
||
if (!content) return;
|
||
const bar = document.createElement('div');
|
||
bar.className = 'bookslm-msg-actions';
|
||
|
||
bar.appendChild(this._actionBtn(t('bookslm.copy'), 'copy', t('bookslm.copy'), () => {
|
||
this._copyText(content);
|
||
}));
|
||
if (msg.role === 'assistant') {
|
||
bar.appendChild(this._actionBtn(t('bookslm.insert'), 'insert', t('bookslm.insert_hint'), () => {
|
||
this._insertIntoEditor(content);
|
||
}));
|
||
}
|
||
wrap.appendChild(bar);
|
||
}
|
||
|
||
_actionBtn(label, name, title, onClick) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = `bookslm-msg-action bookslm-msg-action-${name}`;
|
||
btn.title = title;
|
||
btn.setAttribute('aria-label', title);
|
||
const icon = document.createElement('span');
|
||
icon.setAttribute('data-lucide', name === 'copy' ? 'copy' : 'corner-down-left');
|
||
icon.style.cssText = 'width:12px;height:12px';
|
||
btn.appendChild(icon);
|
||
const text = document.createElement('span');
|
||
text.textContent = label;
|
||
btn.appendChild(text);
|
||
btn.addEventListener('click', onClick);
|
||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||
return btn;
|
||
}
|
||
|
||
/**
|
||
* Append an assistant answer (or a single code block) to the document open
|
||
* in the active editor (Notion “Ajouter”). Supports the three editor
|
||
* surfaces: CodeMirror (« Editer »), the Forge iframe and the plain textarea
|
||
* fallback. No-ops with a toast when no editor session is open.
|
||
*/
|
||
_insertIntoEditor(text) {
|
||
const value = String(text == null ? '' : text);
|
||
if (!value.trim()) return;
|
||
|
||
// 1. CodeMirror editor (« Editer »).
|
||
const view = state.editorView;
|
||
if (view && view.state && typeof view.dispatch === 'function') {
|
||
try {
|
||
const at = view.state.selection.main.to;
|
||
const insert = (at > 0 ? '\n' : '') + value;
|
||
view.dispatch({
|
||
changes: { from: at, insert },
|
||
selection: { anchor: at + insert.length },
|
||
});
|
||
if (typeof view.focus === 'function') view.focus();
|
||
showToast(t('bookslm.inserted'), 'success');
|
||
} catch (e) {
|
||
console.warn('AI assistant: insert into editor failed', e);
|
||
showToast(t('bookslm.insert_no_editor'), 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. Forge editor (same-origin iframe): its buffer lives in the child
|
||
// document, so the insertion is delegated with a postMessage.
|
||
const forge = document.getElementById('forge-iframe');
|
||
if (forge && forge.contentWindow) {
|
||
try {
|
||
forge.contentWindow.postMessage({ type: 'parent-insert', text: value }, '*');
|
||
showToast(t('bookslm.inserted'), 'success');
|
||
} catch (e) {
|
||
console.warn('AI assistant: insert into Forge failed', e);
|
||
showToast(t('bookslm.insert_no_editor'), 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 3. Plain textarea fallback (CodeMirror failed to load).
|
||
const ta = state.fallbackEditorEl;
|
||
if (ta && typeof ta.value === 'string') {
|
||
const start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
|
||
const end = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length;
|
||
const insert = (start > 0 ? '\n' : '') + value;
|
||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||
const pos = start + insert.length;
|
||
if (typeof ta.setSelectionRange === 'function') ta.setSelectionRange(pos, pos);
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
if (typeof ta.focus === 'function') ta.focus();
|
||
showToast(t('bookslm.inserted'), 'success');
|
||
return;
|
||
}
|
||
|
||
showToast(t('bookslm.insert_no_editor'), 'info');
|
||
}
|
||
|
||
/**
|
||
* #102 — Add a discreet « Ajouter » button to every fenced code block of an
|
||
* answer, so a single proposed section can be inserted instead of the whole
|
||
* reply. The button reuses the message action styling.
|
||
*/
|
||
_enhanceCodeBlocks(bubble) {
|
||
if (!bubble || typeof bubble.querySelectorAll !== 'function') return;
|
||
const blocks = bubble.querySelectorAll('pre');
|
||
blocks.forEach((pre) => {
|
||
const code = pre.querySelector('code');
|
||
if (!code) return;
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'bookslm-code-block';
|
||
pre.parentNode.insertBefore(wrap, pre);
|
||
wrap.appendChild(pre);
|
||
const btn = this._actionBtn(
|
||
t('bookslm.insert_block'),
|
||
'insert',
|
||
t('bookslm.insert_block_hint'),
|
||
() => this._insertIntoEditor(code.textContent),
|
||
);
|
||
btn.classList.add('bookslm-code-insert');
|
||
wrap.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
_copyText(text) {
|
||
const done = () => showToast(t('bookslm.copied'), 'success');
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done).catch(() => this._fallbackCopy(text, done));
|
||
return;
|
||
}
|
||
} catch { /* fall through */ }
|
||
this._fallbackCopy(text, done);
|
||
}
|
||
|
||
// ── Actions (general assistant) ─────────────────────────────────────
|
||
|
||
_extractActions(text) {
|
||
const actions = [];
|
||
const re = /```obsigate-action\s*\n([\s\S]*?)```/g;
|
||
let match;
|
||
while ((match = re.exec(text)) !== null) {
|
||
try {
|
||
const obj = JSON.parse(match[1].trim());
|
||
if (obj && typeof obj.action === 'string') actions.push(obj);
|
||
} catch { /* malformed block — ignore */ }
|
||
}
|
||
return { text: text.replace(re, '').trim(), actions };
|
||
}
|
||
|
||
_renderActionCard(action) {
|
||
const card = document.createElement('div');
|
||
card.className = 'bookslm-action';
|
||
const isDir = action.action === 'create_directory';
|
||
const label = isDir
|
||
? t('ai.action_create_dir', { path: action.path || '' })
|
||
: t('ai.action_create_file', { path: action.path || '' });
|
||
const meta = document.createElement('div');
|
||
meta.className = 'bookslm-action-meta';
|
||
meta.innerHTML = `<span class="bookslm-action-icon">${isDir ? '📁' : '📄'}</span>`;
|
||
const textEl = document.createElement('span');
|
||
textEl.textContent = label;
|
||
meta.appendChild(textEl);
|
||
card.appendChild(meta);
|
||
|
||
const apply = document.createElement('button');
|
||
apply.className = 'bookslm-action-apply';
|
||
apply.textContent = t('ai.action_apply');
|
||
apply.addEventListener('click', async () => {
|
||
apply.disabled = true;
|
||
apply.textContent = t('ai.action_applying');
|
||
try {
|
||
await this._applyAction(action);
|
||
card.classList.add('done');
|
||
apply.textContent = t('ai.action_applied');
|
||
} catch (e) {
|
||
apply.disabled = false;
|
||
apply.textContent = t('ai.action_apply');
|
||
showToast(t('ai.action_failed', { error: e.message }), 'error');
|
||
}
|
||
});
|
||
card.appendChild(apply);
|
||
return card;
|
||
}
|
||
|
||
async _applyAction(action) {
|
||
if (!action.vault || !action.path) throw new Error('missing vault/path');
|
||
if (action.action === 'create_file') {
|
||
await api(`/api/file/${encodeURIComponent(action.vault)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: action.path, content: action.content || '' }),
|
||
});
|
||
showToast(t('ai.action_created_file', { path: action.path }), 'success');
|
||
} else if (action.action === 'create_directory') {
|
||
await api(`/api/directory/${encodeURIComponent(action.vault)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: action.path }),
|
||
});
|
||
showToast(t('ai.action_created_dir', { path: action.path }), 'success');
|
||
} else {
|
||
throw new Error(`unknown action: ${action.action}`);
|
||
}
|
||
try {
|
||
const m = await import('./sidebar.js');
|
||
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
|
||
await m.refreshSidebarTreePreservingState();
|
||
}
|
||
} catch { /* tree refresh is best-effort */ }
|
||
}
|
||
|
||
// ── Tool activity & confirmations (agent mode) ──────────────────────
|
||
|
||
/** Human one-liner for a recorded step (backend label key or legacy name). */
|
||
_stepText(call) {
|
||
const step = call.step;
|
||
if (step && step.key) {
|
||
const key = `ai.step.${step.key}`;
|
||
const label = t(key, step.params || {});
|
||
if (label && label !== key) return label;
|
||
}
|
||
return t('ai.tool_call', { name: call.name });
|
||
}
|
||
|
||
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
|
||
_chevron() {
|
||
const c = document.createElement('span');
|
||
c.className = 'bookslm-chevron';
|
||
c.setAttribute('aria-hidden', 'true');
|
||
return c;
|
||
}
|
||
|
||
/** True while this very message is the one being produced. */
|
||
_isRunningMessage(msg) {
|
||
return !!this._isLoading && this._messages[this._messages.length - 1] === msg;
|
||
}
|
||
|
||
/**
|
||
* Agent tool calls collapse into a discreet “N étapes” block (Notion-style).
|
||
*
|
||
* The header carries an animated indicator while the agent works; expanding
|
||
* it reveals the chronological steps, where each reasoning note becomes a
|
||
* « Réflexion » sub-section and web searches add a clickable sources list.
|
||
* Open states live on the message so the re-render of every streamed token
|
||
* never collapses what the user expanded.
|
||
*/
|
||
_renderToolActivity(toolCalls, msg, running) {
|
||
const wrap = document.createElement('details');
|
||
wrap.className = 'bookslm-tool-trace';
|
||
wrap.open = !!(msg && msg._stepsOpen);
|
||
|
||
const summary = document.createElement('summary');
|
||
if (running) {
|
||
const dots = this._dots();
|
||
dots.classList.add('bookslm-steps-dots');
|
||
summary.appendChild(dots);
|
||
}
|
||
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
|
||
const label = document.createElement('span');
|
||
label.className = 'bookslm-steps-label';
|
||
label.textContent = t(countKey, { count: toolCalls.length });
|
||
summary.appendChild(label);
|
||
summary.appendChild(this._chevron());
|
||
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
|
||
wrap.appendChild(summary);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'bookslm-steps-body';
|
||
const sources = [];
|
||
for (const call of toolCalls) {
|
||
// A reasoning note is its own collapsible sub-section, not a line.
|
||
if (call.step && call.step.key === 'thought') {
|
||
body.appendChild(this._renderThought(call, msg));
|
||
continue;
|
||
}
|
||
const line = document.createElement('div');
|
||
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
|
||
line.textContent = `${call.ok === false ? '⚠' : '·'} ${this._stepText(call)}`;
|
||
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
|
||
else if (call.step && call.step.params && call.step.params.value) line.title = call.step.params.value;
|
||
body.appendChild(line);
|
||
if (Array.isArray(call.sources)) {
|
||
for (const s of call.sources) {
|
||
if (s && s.url) sources.push(s);
|
||
}
|
||
}
|
||
}
|
||
if (sources.length) body.appendChild(this._renderSources(sources, msg));
|
||
wrap.appendChild(body);
|
||
|
||
wrap.addEventListener('toggle', () => { if (msg) msg._stepsOpen = wrap.open; });
|
||
return wrap;
|
||
}
|
||
|
||
/** Animated three-dot activity indicator (pure CSS, reduced-motion aware). */
|
||
_dots() {
|
||
const dots = document.createElement('span');
|
||
dots.className = 'bookslm-dots';
|
||
dots.setAttribute('aria-hidden', 'true');
|
||
dots.innerHTML = '<i></i><i></i><i></i>';
|
||
return dots;
|
||
}
|
||
|
||
/** One collapsible « Réflexion » block holding the model's reasoning note. */
|
||
_renderThought(call, msg) {
|
||
const text = (call.step && call.step.params && call.step.params.value) || '';
|
||
const box = document.createElement('details');
|
||
box.className = 'bookslm-thought';
|
||
box.open = !!(msg && msg._thoughtOpen && msg._thoughtOpen[text]);
|
||
const summary = document.createElement('summary');
|
||
const label = document.createElement('span');
|
||
label.className = 'bookslm-thought-label';
|
||
label.textContent = t('ai.thought_label');
|
||
summary.appendChild(label);
|
||
summary.appendChild(this._chevron());
|
||
box.appendChild(summary);
|
||
const body = document.createElement('div');
|
||
body.className = 'bookslm-thought-text';
|
||
body.textContent = text;
|
||
box.appendChild(body);
|
||
box.addEventListener('toggle', () => {
|
||
if (!msg) return;
|
||
msg._thoughtOpen = msg._thoughtOpen || {};
|
||
msg._thoughtOpen[text] = box.open;
|
||
});
|
||
return box;
|
||
}
|
||
|
||
/** Web sources gathered from the run's searches, as real links. */
|
||
_renderSources(list, msg) {
|
||
const box = document.createElement('details');
|
||
box.className = 'bookslm-web-sources';
|
||
// Visible by default: the point of the section is to expose the links.
|
||
box.open = !(msg && msg._sourcesOpen === false);
|
||
const summary = document.createElement('summary');
|
||
const label = document.createElement('span');
|
||
label.className = 'bookslm-web-sources-label';
|
||
label.textContent = t('ai.sources_label', { count: list.length });
|
||
summary.appendChild(label);
|
||
summary.appendChild(this._chevron());
|
||
box.appendChild(summary);
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'bookslm-web-sources-list';
|
||
for (const s of list) {
|
||
const li = document.createElement('li');
|
||
const a = document.createElement('a');
|
||
a.href = s.url;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.textContent = s.title || s.url;
|
||
a.title = s.url;
|
||
li.appendChild(a);
|
||
ul.appendChild(li);
|
||
}
|
||
box.appendChild(ul);
|
||
box.addEventListener('toggle', () => { if (msg) msg._sourcesOpen = box.open; });
|
||
return box;
|
||
}
|
||
|
||
_renderConfirmationCard(msg) {
|
||
const conf = msg.confirmation;
|
||
const pending = conf.pending || {};
|
||
const error = pending.error || pending;
|
||
const tool = error.tool || 'action';
|
||
const args = error.arguments || {};
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'bookslm-action bookslm-confirm';
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'bookslm-action-meta';
|
||
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
|
||
const textEl = document.createElement('span');
|
||
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
|
||
meta.appendChild(textEl);
|
||
card.appendChild(meta);
|
||
|
||
const diffHost = document.createElement('div');
|
||
diffHost.className = 'bookslm-confirm-diff';
|
||
if (conf._diffHtml) {
|
||
diffHost.innerHTML = conf._diffHtml;
|
||
} else if (!conf._diffLoading) {
|
||
conf._diffLoading = true;
|
||
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
|
||
}
|
||
card.appendChild(diffHost);
|
||
|
||
const apply = document.createElement('button');
|
||
apply.className = 'bookslm-action-apply';
|
||
apply.textContent = t('ai.action_apply');
|
||
apply.addEventListener('click', async () => {
|
||
apply.disabled = true;
|
||
apply.textContent = t('ai.action_applying');
|
||
try {
|
||
await this._applyConfirmation(msg);
|
||
apply.textContent = t('ai.action_applied');
|
||
} catch (e) {
|
||
apply.disabled = false;
|
||
apply.textContent = t('ai.action_apply');
|
||
showToast(t('ai.action_failed', { error: e.message }), 'error');
|
||
}
|
||
});
|
||
card.appendChild(apply);
|
||
return card;
|
||
}
|
||
|
||
async _fillConfirmationDiff(args, conf) {
|
||
const proposed = args.content;
|
||
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
|
||
let current = null;
|
||
try {
|
||
current = await this._fetchCurrentContent(args.vault, args.path);
|
||
} catch {
|
||
current = null;
|
||
}
|
||
const title = `<div class="bookslm-diff-title">${this._escapeHtml(t('ai.diff_preview'))}</div>`;
|
||
if (current == null) {
|
||
// New file (or unreadable): show everything as an addition.
|
||
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines([{ t: '+', s: proposed }])}</pre>`;
|
||
return;
|
||
}
|
||
const lines = this._lineDiff(current, proposed);
|
||
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines(lines)}</pre>`;
|
||
}
|
||
|
||
async _fetchCurrentContent(vault, path) {
|
||
const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`);
|
||
return data && typeof data.raw === 'string' ? data.raw : '';
|
||
}
|
||
|
||
/** Minimal line diff (LCS) between the current and the proposed content. */
|
||
_lineDiff(oldText, newText) {
|
||
const a = String(oldText || '').split('\n');
|
||
const b = String(newText || '').split('\n');
|
||
const n = a.length;
|
||
const m = b.length;
|
||
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||
for (let i = n - 1; i >= 0; i--) {
|
||
for (let j = m - 1; j >= 0; j--) {
|
||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||
}
|
||
}
|
||
const out = [];
|
||
let i = 0;
|
||
let j = 0;
|
||
while (i < n && j < m) {
|
||
if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; }
|
||
else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; }
|
||
else { out.push({ t: '+', s: b[j] }); j++; }
|
||
}
|
||
while (i < n) out.push({ t: '-', s: a[i++] });
|
||
while (j < m) out.push({ t: '+', s: b[j++] });
|
||
return out;
|
||
}
|
||
|
||
_renderDiffLines(lines) {
|
||
const cap = 400;
|
||
const shown = lines.length > cap ? lines.slice(0, cap) : lines;
|
||
let html = shown.map((l) => {
|
||
const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx');
|
||
return `<span class="${cls}">${this._escapeHtml(l.t + ' ' + l.s)}</span>`;
|
||
}).join('\n');
|
||
if (lines.length > cap) {
|
||
html += `\n<span class="bookslm-diff-ctx">… ${lines.length - cap}</span>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Build a readable Error from a non-OK /chat or /agent response. BUG-046:
|
||
* a 422 validation error carries `detail` as an ARRAY of objects (FastAPI),
|
||
* which `new Error(detail)` stringified to "[object Object]" in the
|
||
* "Échec de l'action" toast. Flatten arrays / objects into their messages.
|
||
*/
|
||
async _responseError(resp) {
|
||
let detail = `HTTP ${resp.status}`;
|
||
try {
|
||
const body = await resp.json();
|
||
const d = body && body.detail;
|
||
if (typeof d === 'string' && d) detail = d;
|
||
else if (Array.isArray(d) && d.length) {
|
||
detail = d.map((item) => (item && (item.msg || item.message)) || JSON.stringify(item)).join(' · ');
|
||
} else if (d && typeof d === 'object') {
|
||
detail = d.msg || d.message || JSON.stringify(d);
|
||
}
|
||
} catch { /* no json body */ }
|
||
return new Error(detail);
|
||
}
|
||
|
||
async _applyConfirmation(msg) {
|
||
const conf = msg.confirmation;
|
||
if (!conf || conf._applying) return;
|
||
conf._applying = true;
|
||
|
||
const payload = {
|
||
...(msg.payload || {}),
|
||
confirm: conf.pending,
|
||
confirm_messages: conf.messages,
|
||
};
|
||
|
||
this._isLoading = true;
|
||
this._abortCtrl = new AbortController();
|
||
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
|
||
// BUG-046: carry the original request payload over to the continuation.
|
||
// When an applied tool failed and the model re-proposed a confirmation,
|
||
// the continuation used to have `payload: null`: the second "Appliquer"
|
||
// then resumed without `message` → 422 → "[object Object]" toast.
|
||
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
|
||
try {
|
||
let resp = await this._postChat(payload);
|
||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||
await AuthManager.refreshAccessToken();
|
||
resp = await this._postChat(payload);
|
||
}
|
||
if (!resp.ok) {
|
||
throw await this._responseError(resp);
|
||
}
|
||
// The confirmation is resolved: drop the card and show the continuation.
|
||
msg.confirmation = null;
|
||
this._messages.push(continuation);
|
||
this._renderMessages({ anchor: true });
|
||
await this._streamResponse(resp, continuation, payload);
|
||
} finally {
|
||
conf._applying = false;
|
||
this._isLoading = false;
|
||
this._abortCtrl = null;
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
this._renderMessages();
|
||
this._saveHistory();
|
||
}
|
||
}
|
||
|
||
// ── Messaging ───────────────────────────────────────────────────────
|
||
|
||
async _sendMessage() {
|
||
const textarea = this._panel.querySelector('textarea');
|
||
const text = (textarea.value || '').trim();
|
||
if (!text || this._isLoading) return;
|
||
|
||
// Admin slash-commands are handled locally (no LLM round-trip).
|
||
if (/^\/(?:help|providers|keys|provider|model)(?:\s|$)/i.test(text)) {
|
||
this._hideMenus();
|
||
textarea.value = '';
|
||
textarea.style.height = 'auto';
|
||
if (!this._maybeRunAdminWithArg(text)) {
|
||
this._runAdminCommand(text.slice(1).split(/\s+/)[0].toLowerCase());
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Vision gate: block sending images to a non-vision model.
|
||
if (this._images.length) {
|
||
const visionOk = await this._modelSupportsVision();
|
||
if (!visionOk) {
|
||
showToast(t('ai.vision_unsupported'), 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
textarea.value = '';
|
||
textarea.style.height = 'auto';
|
||
|
||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||
if (sugEl) sugEl.innerHTML = '';
|
||
|
||
// Snapshot prior turns BEFORE appending the new user message.
|
||
const history = this._messages
|
||
.filter((m) => m.content && m.content.trim())
|
||
.map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
this._messages.push({ role: 'user', content: text });
|
||
// Instant anchor: the post must already sit at the top when the answer
|
||
// starts streaming (a smooth animation would leave it at the bottom).
|
||
this._renderMessages({ anchor: true, instant: true });
|
||
|
||
let provider = null;
|
||
let model = null;
|
||
try {
|
||
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
|
||
provider = picker.provider || null;
|
||
model = picker.model || null;
|
||
} catch { /* */ }
|
||
|
||
const payload = {
|
||
mode: this._mode,
|
||
vault: this._contextVault(),
|
||
directory: this._directory,
|
||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||
extra_files: this._adhocFiles.map((f) => f.path),
|
||
extra_directories: this._adhocDirs.map((d) => d.path),
|
||
images: this._images.map((img) => (
|
||
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
|
||
)),
|
||
skill: this._activeSkill,
|
||
message: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text,
|
||
conversation_history: history,
|
||
provider,
|
||
model,
|
||
app_context: this._buildAppContext(),
|
||
};
|
||
|
||
const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload };
|
||
this._messages.push(assistantMsg);
|
||
// Loading must be flagged *before* this render: otherwise it would take
|
||
// the "restore previous scroll" branch and cancel the anchor applied to
|
||
// the user post, leaving the new question at the bottom until the first
|
||
// token arrives.
|
||
this._isLoading = true;
|
||
this._renderMessages({ anchor: true, instant: true });
|
||
|
||
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
|
||
this._abortCtrl = new AbortController();
|
||
this._setActivity('working', t('ai.activity_sending'));
|
||
|
||
try {
|
||
let resp = await this._postChat(payload);
|
||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||
await AuthManager.refreshAccessToken();
|
||
resp = await this._postChat(payload);
|
||
}
|
||
if (!resp.ok) {
|
||
let detail = `HTTP ${resp.status}`;
|
||
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
|
||
throw new Error(detail);
|
||
}
|
||
|
||
this._setActivity('working', t('ai.activity_streaming'));
|
||
await this._streamResponse(resp, assistantMsg, payload);
|
||
|
||
if (!assistantMsg.sources.length && this._contextFiles.length) {
|
||
const contentLower = assistantMsg.content.toLowerCase();
|
||
assistantMsg.sources = this._contextFiles
|
||
.filter((f) => {
|
||
const name = (f.path || f || '').split('/').pop().toLowerCase();
|
||
return name && contentLower.includes(name);
|
||
})
|
||
.map((f) => f.path || f);
|
||
}
|
||
this._setActivity('done', t('ai.activity_done'));
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') {
|
||
assistantMsg.content = `⚠ Error: ${e.message}`;
|
||
console.warn('AI assistant chat error:', e);
|
||
this._setActivity('error', t('ai.activity_error'));
|
||
} else {
|
||
this._setActivity('idle');
|
||
}
|
||
}
|
||
|
||
this._isLoading = false;
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
this._abortCtrl = null;
|
||
this._renderMessages();
|
||
this._saveHistory();
|
||
}
|
||
|
||
/** POST to /chat or /agent depending on the agent-mode toggle. */
|
||
_postChat(payload) {
|
||
// Images require the plain multimodal chat endpoint (the agent loop is
|
||
// text/tool oriented).
|
||
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
|
||
const endpoint = (this._agentMode && !hasImages)
|
||
? '/api/ai/bookslm/agent'
|
||
: '/api/ai/bookslm/chat';
|
||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||
return fetch(endpoint, {
|
||
method: 'POST',
|
||
headers,
|
||
credentials: 'include',
|
||
body: JSON.stringify(payload),
|
||
signal: this._abortCtrl ? this._abortCtrl.signal : undefined,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Consume an SSE response, feeding tokens/tool/confirmation events into
|
||
* `assistantMsg`. Shared by the initial send and the confirmation resume.
|
||
*/
|
||
async _streamResponse(resp, assistantMsg, payload) {
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let currentEvent = 'message';
|
||
|
||
const handleLine = (line) => {
|
||
if (line.startsWith('event:')) {
|
||
currentEvent = line.slice(6).trim();
|
||
return;
|
||
}
|
||
if (!line.startsWith('data:')) return;
|
||
const raw = line.slice(5).trim();
|
||
if (!raw || raw === '[DONE]') return;
|
||
let data;
|
||
try {
|
||
data = JSON.parse(raw);
|
||
} catch {
|
||
assistantMsg.content += raw;
|
||
return;
|
||
}
|
||
|
||
if (currentEvent === 'tool') {
|
||
assistantMsg.toolCalls.push({
|
||
name: data.name,
|
||
ok: data.ok !== false,
|
||
arguments: data.arguments || {},
|
||
step: data.step || null,
|
||
sources: Array.isArray(data.sources) ? data.sources : [],
|
||
});
|
||
// #93 — a vault write must be reflected in the displayed document.
|
||
this._notifyFileWritten(data);
|
||
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
|
||
return;
|
||
}
|
||
// Intermediate reasoning note streamed by the agent (Notion-style
|
||
// ". thought" line inside the steps block).
|
||
if (currentEvent === 'step' && data.step) {
|
||
assistantMsg.toolCalls.push({ name: '', ok: true, arguments: {}, step: data.step });
|
||
this._setActivity('working', t('ai.activity_thinking'));
|
||
return;
|
||
}
|
||
if (currentEvent === 'confirmation') {
|
||
assistantMsg.confirmation = {
|
||
pending: data.pending || data,
|
||
messages: data.messages || [],
|
||
};
|
||
this._setActivity('working', t('ai.activity_confirmation'));
|
||
return;
|
||
}
|
||
if (currentEvent === 'error' || data.error) {
|
||
const msg = data.error || t('bookslm.error');
|
||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||
this._setActivity('error', t('ai.activity_error'));
|
||
return;
|
||
}
|
||
const chunk = data.token || data.content || '';
|
||
// Remember which provider/model actually answered, so a discreet tag
|
||
// can be shown above the assistant's text block.
|
||
if (data.provider) assistantMsg.provider = data.provider;
|
||
if (data.model) assistantMsg.model = data.model;
|
||
if (chunk) {
|
||
if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming'));
|
||
assistantMsg.content += chunk;
|
||
}
|
||
if (data.sources) assistantMsg.sources = data.sources;
|
||
};
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
for (const line of lines) handleLine(line);
|
||
this._renderMessages();
|
||
}
|
||
if (buffer) handleLine(buffer);
|
||
}
|
||
|
||
// ── 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 '';
|
||
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). The target may contain spaces
|
||
// (`Mon dossier/Ma note.md`), an optional <angle-bracket> wrapper and an
|
||
// optional "title" — all stripped before resolving.
|
||
s = s.replace(/\[([^\]\n]+)\]\(([^)\n]*)\)/g, (_m, label, target) => {
|
||
let linkTarget = String(target).trim();
|
||
if (linkTarget.startsWith('<') && linkTarget.endsWith('>')) linkTarget = linkTarget.slice(1, -1).trim();
|
||
linkTarget = linkTarget.replace(/\s+["'][^"']*["']$/, '').trim();
|
||
if (!linkTarget) return _m;
|
||
return put(this._renderMdLink(label, linkTarget));
|
||
});
|
||
|
||
// Bare mentions whose file/directory names contain spaces. Run BEFORE the
|
||
// space-free pass so the full path is captured, and link only when the
|
||
// candidate exists in the vault index — ordinary prose is never turned into
|
||
// a dead link.
|
||
s = this._linkifySpacePaths(s, put);
|
||
|
||
// Bare `dir/file.ext` mentions (no spaces).
|
||
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>`;
|
||
}
|
||
// Links emitted by models are sometimes percent-encoded (spaces as %20).
|
||
let decoded = this._decodeEntities(target);
|
||
if (/%[0-9a-f]{2}/i.test(decoded)) {
|
||
try { decoded = decodeURIComponent(decoded); } catch { /* keep as-is */ }
|
||
}
|
||
return this._pathLink(label, decoded);
|
||
}
|
||
|
||
/** Build a clickable link for a vault-relative file or directory path. */
|
||
_pathLink(display, rawPath) {
|
||
const clean = this._normalizeLinkPath(this._decodeEntities(rawPath));
|
||
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) {
|
||
const p = String(path || '');
|
||
// A bare filename (no directory) is copied to the clipboard instead of
|
||
// being treated as a vault path — avoids "File not found" dead links.
|
||
if (!p.includes('/')) return FILE_EXT_RE.test(p) ? 'name' : 'dir';
|
||
return FILE_EXT_RE.test(p) ? 'file' : 'dir';
|
||
}
|
||
|
||
_looksLikePath(value) {
|
||
if (value == null) return false;
|
||
const p = String(value).trim();
|
||
if (!p || /[\n\r]/.test(p)) return false;
|
||
// Allow spaces and accented characters inside file/directory names
|
||
// (`Ma note.md`, `Recettes/Préparation.md`), but keep the character set
|
||
// tight so prose and code snippets are not mistaken for paths.
|
||
if (!PATH_NAME_RE.test(p)) return false;
|
||
return p.includes('/') || FILE_EXT_RE.test(p);
|
||
}
|
||
|
||
_escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
_escapeAttr(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
_decodeEntities(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ── Session history ─────────────────────────────────────────────────
|
||
//
|
||
// #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
|
||
? documentsSignature(this._documents)
|
||
: (this._directory || this._vault || 'general');
|
||
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()}`;
|
||
}
|
||
|
||
_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: '',
|
||
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());
|
||
if (!raw) return [];
|
||
const list = JSON.parse(raw);
|
||
if (!Array.isArray(list)) return [];
|
||
return list
|
||
.filter((s) => s && typeof s === 'object')
|
||
.map((s) => this._normalizeSession(s));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** 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) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
_persistableMessages(messages) {
|
||
return (messages || []).map(({ confirmation, payload, ...rest }) => rest);
|
||
}
|
||
|
||
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 */ }
|
||
}
|
||
|
||
/** 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(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._positionSession(created);
|
||
this._currentSessionId = created.id;
|
||
this._dirtySessionIds.add(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;
|
||
}
|
||
|
||
/** 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 = 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) {
|
||
session = this._createSession([]);
|
||
this._sessions.push(session);
|
||
this._dirtySessionIds.add(session.id);
|
||
} else if (id === this._currentSessionId) {
|
||
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();
|
||
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 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(meta);
|
||
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 {
|
||
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();
|
||
export default booksLM;
|
||
export { BooksLM };
|