feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Contexte: - Repertoire (mode historique BooksLM, via menu contextuel dossier) - Documents (bouton flottant quand des documents sont ouverts) - General (bouton flottant sans document: aide app + creation de fichiers) - Le contexte est affiche dans le header; suggestions adaptees au mode - Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto) Backend: - /api/ai/bookslm/context et /chat acceptent mode + context_files - collect_files_context(), empty_context(), build_general_system_prompt() - mode documents degrade vers general si aucun fichier lisible Corrige: - bouton flottant passait le chemin d'un fichier comme repertoire -> erreur 'Aucun fichier markdown trouve dans ce dossier' - panneau precedent masque (localStorage) ne se rouvrait plus - selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles) - Entree envoie, Ctrl+Entree insere un saut de ligne Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
This commit is contained in:
+11
-34
@@ -1,26 +1,14 @@
|
||||
// ObsiGate — Floating Action Button (FAB) to open/close the BooksLM AI sidebar.
|
||||
// Always visible in the bottom-right corner; hides while BooksLM is open.
|
||||
// ObsiGate — Floating Action Button (FAB) for the AI Assistant sidebar.
|
||||
// Always visible in the bottom-right corner; hides while the assistant panel
|
||||
// is expanded. Clicking it opens the assistant in the context that matches
|
||||
// the current application state (open documents, or General when none).
|
||||
import booksLM from './bookslm.js';
|
||||
import { state } from './state.js';
|
||||
import { showToast, TabManager } from './ui.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
let _fab = null;
|
||||
|
||||
// Mirror palette.js getCurrentFile(): prefer the active tab, then fall back to state.
|
||||
function getCurrentFile() {
|
||||
const id = TabManager._activeTabId;
|
||||
if (id) {
|
||||
const tab = TabManager._tabs.find((x) => x.id === id);
|
||||
if (tab) return { vault: tab.vault, path: tab.path };
|
||||
}
|
||||
if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The BooksLM panel is considered visible when it has `.open` without `.hidden`
|
||||
// (the header toggle collapses it off-screen via `.hidden`).
|
||||
// The panel is visible when it has `.open` without `.hidden`.
|
||||
function isBooksLmVisible() {
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
|
||||
@@ -36,25 +24,14 @@ function syncVisibility() {
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
const f = getCurrentFile();
|
||||
if (!f) {
|
||||
showToast(t('toast.no_open_file'), 'error');
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
// Collapsed panel: reveal it instead of reopening.
|
||||
if (panel && panel.classList.contains('open') && panel.classList.contains('hidden')) {
|
||||
booksLM._toggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
const sameDir = booksLM._isOpen && booksLM._vault === f.vault && booksLM._directory === f.path;
|
||||
if (sameDir) {
|
||||
// Already open for this directory: re-show a collapsed panel, or close it.
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
if (panel && panel.classList.contains('hidden')) {
|
||||
booksLM._toggleSidebar();
|
||||
} else {
|
||||
booksLM.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await booksLM.open(f.vault, f.path);
|
||||
// Otherwise open (or re-focus) the assistant for the current context.
|
||||
await booksLM.openForCurrentContext();
|
||||
}
|
||||
|
||||
export function initAIFab() {
|
||||
|
||||
+369
-128
@@ -1,14 +1,62 @@
|
||||
// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
|
||||
// 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 } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
export const MODE = Object.freeze({
|
||||
DIRECTORY: 'directory',
|
||||
DOCUMENTS: 'documents',
|
||||
GENERAL: 'general',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -19,100 +67,121 @@ class BooksLM {
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
async open(vault, directory) {
|
||||
if (this._isOpen && this._vault === vault && this._directory === directory) {
|
||||
this.close();
|
||||
/**
|
||||
* 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]
|
||||
*/
|
||||
async openContext(opts = {}) {
|
||||
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 =
|
||||
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._isLoading = true;
|
||||
|
||||
// Restore history
|
||||
this._loadHistory();
|
||||
|
||||
// Build panel if needed
|
||||
if (!this._panel) {
|
||||
this._panel = this._render();
|
||||
document.body.appendChild(this._panel);
|
||||
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();
|
||||
this._showSuggestions();
|
||||
|
||||
// Open with animation
|
||||
requestAnimationFrame(() => {
|
||||
this._panel.classList.add('open');
|
||||
let hidden = false;
|
||||
// Apply persisted hidden state (user toggled off in a previous session)
|
||||
try {
|
||||
const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
|
||||
if (savedHidden === 'true') {
|
||||
this._panel.classList.add('hidden');
|
||||
this._updateToggleIcon();
|
||||
hidden = true;
|
||||
}
|
||||
} catch { /* */ }
|
||||
document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } }));
|
||||
document.dispatchEvent(new CustomEvent('bookslm:opened', {
|
||||
detail: { mode, vault, directory, documents },
|
||||
}));
|
||||
});
|
||||
this._isOpen = true;
|
||||
|
||||
// Load context (authenticated — `api` injects the bearer token and
|
||||
// transparently refreshes it once on 401).
|
||||
try {
|
||||
const data = await api('/api/ai/bookslm/context', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vault, directory }),
|
||||
});
|
||||
this._contextFiles = data.files || [];
|
||||
this._updateStatus(data);
|
||||
this._showSuggestions();
|
||||
} catch (e) {
|
||||
console.warn('BooksLM: context load failed', e);
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ error: e.message });
|
||||
}
|
||||
await this._loadContext();
|
||||
this._isLoading = false;
|
||||
}
|
||||
|
||||
/** Backwards-compatible helper: open the assistant on a directory. */
|
||||
async open(vault, directory) {
|
||||
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'));
|
||||
// Abort any in-flight request
|
||||
if (this._abortCtrl) {
|
||||
this._abortCtrl.abort();
|
||||
this._abortCtrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the BooksLM sidebar visibility (collapsed/hidden to the right edge
|
||||
* while the panel stays mounted). The state is persisted in localStorage so
|
||||
* the user choice survives page reloads — same UX as the left sidebar toggle.
|
||||
*
|
||||
* The panel stays in the DOM (no teardown); only a CSS transform is applied,
|
||||
* which is what makes this fast and matches the right-sidebar pattern in
|
||||
* ObsiGate.
|
||||
*/
|
||||
_toggleSidebar() {
|
||||
_setHidden(hidden) {
|
||||
if (!this._panel) return;
|
||||
const isHidden = this._panel.classList.toggle('hidden');
|
||||
this._panel.classList.toggle('hidden', hidden);
|
||||
try {
|
||||
localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the toggle icon between "panel-right-close" (visible) and
|
||||
* "panel-right-open" (hidden) so the button visually reflects the state.
|
||||
*/
|
||||
_updateToggleIcon() {
|
||||
if (!this._panel) return;
|
||||
const btn = this._panel.querySelector('.bookslm-btn-toggle');
|
||||
@@ -131,14 +200,12 @@ class BooksLM {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
}
|
||||
if (!this._isOpen && this._vault && this._directory) {
|
||||
this.open(this._vault, this._directory);
|
||||
}
|
||||
}
|
||||
|
||||
exportConversation() {
|
||||
if (!this._messages.length) return;
|
||||
let md = `# BooksLM — ${this._directory}\n\n`;
|
||||
const label = 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`;
|
||||
@@ -146,17 +213,79 @@ class BooksLM {
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `bookslm-${this._directory.replace(/\//g, '_')}.md`;
|
||||
a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
openFile(path) {
|
||||
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
|
||||
detail: { vault: this._vault, path }
|
||||
detail: { vault: this._vault, path },
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Context ─────────────────────────────────────────────────────────
|
||||
|
||||
async _loadContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
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._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.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'),
|
||||
subtitle: t('ai.context_general_desc'),
|
||||
};
|
||||
}
|
||||
|
||||
_contextTitle() {
|
||||
return this._contextMeta().title;
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
@@ -167,30 +296,41 @@ class BooksLM {
|
||||
<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>📚</span>
|
||||
<span class="bookslm-title"></span>
|
||||
<span class="bookslm-picker-host" style="margin-left:auto"></span>
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
|
||||
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
|
||||
<button class="bookslm-btn-close" title="✕">✕</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-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-toolbar">
|
||||
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
|
||||
<span class="bookslm-picker-host"></span>
|
||||
</div>
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<div class="bookslm-input-area">
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1"></textarea>
|
||||
<button class="bookslm-btn-send">${t('bookslm.send')}</button>
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
|
||||
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
|
||||
</div>
|
||||
<div class="bookslm-input-hint">${t('ai.input_hint')}</div>
|
||||
`;
|
||||
|
||||
// Inject the provider/model picker asynchronously (depends on /api/ai/status)
|
||||
// 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) pickerHost.replaceWith(picker);
|
||||
if (!picker || !pickerHost) return;
|
||||
pickerHost.replaceWith(picker);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}).catch(() => { /* ignore */ });
|
||||
|
||||
// Wire events
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
|
||||
@@ -198,22 +338,36 @@ class BooksLM {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Send button
|
||||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||||
|
||||
// Textarea auto-grow + Ctrl+Enter
|
||||
const textarea = panel.querySelector('textarea');
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl/Cmd+Enter → insert a newline at the caret.
|
||||
e.preventDefault();
|
||||
this._sendMessage();
|
||||
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();
|
||||
});
|
||||
|
||||
return panel;
|
||||
@@ -221,12 +375,14 @@ class BooksLM {
|
||||
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const meta = this._contextMeta();
|
||||
const title = this._panel.querySelector('.bookslm-title');
|
||||
if (title) {
|
||||
title.textContent = this._directory
|
||||
? `📚 ${this._directory.split('/').pop() || this._vault}`
|
||||
: t('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 (icon) icon.setAttribute('data-lucide', meta.icon);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
_updateStatus(data) {
|
||||
@@ -235,37 +391,56 @@ class BooksLM {
|
||||
if (!status) return;
|
||||
|
||||
if (data && data.error) {
|
||||
status.innerHTML = `<span style="color:#f87171">⚠ ${t('bookslm.no_context')}</span>`;
|
||||
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
|
||||
return;
|
||||
}
|
||||
if (data && data.files) {
|
||||
if (data && Array.isArray(data.files)) {
|
||||
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 = data.scope === MODE.GENERAL
|
||||
? t('ai.context_general_desc')
|
||||
: `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
|
||||
status.innerHTML = `
|
||||
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
|
||||
<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.textContent = '⏳ ...';
|
||||
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;
|
||||
|
||||
if (!this._contextFiles.length && !this._isLoading) return;
|
||||
|
||||
const suggestions = [
|
||||
t('bookslm.suggestion_summary'),
|
||||
t('bookslm.suggestion_themes'),
|
||||
t('bookslm.suggestion_contradictions')
|
||||
];
|
||||
for (const s of suggestions) {
|
||||
for (const s of this._suggestionsForContext()) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'bookslm-suggestion';
|
||||
btn.textContent = s;
|
||||
@@ -291,8 +466,14 @@ class BooksLM {
|
||||
bubble.className = `bookslm-bubble ${msg.role}`;
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
bubble.innerHTML = this._renderMarkdown(msg.content || '');
|
||||
// Source badges
|
||||
const { text, actions } = this._extractActions(msg.content || '');
|
||||
bubble.innerHTML = this._renderMarkdown(text);
|
||||
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.sources && msg.sources.length) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'bookslm-sources';
|
||||
@@ -313,10 +494,86 @@ class BooksLM {
|
||||
container.appendChild(bubble);
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// ── 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 */ }
|
||||
}
|
||||
|
||||
// ── Messaging ───────────────────────────────────────────────────────
|
||||
|
||||
async _sendMessage() {
|
||||
@@ -327,34 +584,28 @@ class BooksLM {
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Hide suggestions
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (sugEl) sugEl.innerHTML = '';
|
||||
|
||||
// Snapshot the prior turns BEFORE appending the new user message so the
|
||||
// backend receives the correct `message` + `conversation_history` split.
|
||||
// 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 }));
|
||||
|
||||
// Add user message
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
|
||||
// Add assistant placeholder
|
||||
const assistantMsg = { role: 'assistant', content: '', sources: [] };
|
||||
this._messages.push(assistantMsg);
|
||||
this._renderMessages();
|
||||
this._isLoading = true;
|
||||
|
||||
// Disable send button
|
||||
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
this._abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
// Read provider/model from the picker (localStorage-backed)
|
||||
let provider = null;
|
||||
let model = null;
|
||||
try {
|
||||
@@ -364,17 +615,16 @@ class BooksLM {
|
||||
} catch { /* */ }
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
context_files: this._contextFiles.map((f) => f.path || f),
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
// Authenticated streaming request (raw fetch so we can read the SSE
|
||||
// body; `api()` would consume the JSON response instead).
|
||||
const doFetch = () => {
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch('/api/ai/bookslm/chat', {
|
||||
@@ -421,8 +671,6 @@ class BooksLM {
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
return;
|
||||
}
|
||||
// The backend streams `{token: "..."}`; accept `content` too for
|
||||
// forward compatibility.
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
@@ -431,31 +679,27 @@ class BooksLM {
|
||||
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);
|
||||
|
||||
// Extract sources from context files if mentioned
|
||||
if (!assistantMsg.sources.length) {
|
||||
if (!assistantMsg.sources.length && this._contextFiles.length) {
|
||||
const contentLower = assistantMsg.content.toLowerCase();
|
||||
assistantMsg.sources = this._contextFiles
|
||||
.filter(f => {
|
||||
.filter((f) => {
|
||||
const name = (f.path || f || '').split('/').pop().toLowerCase();
|
||||
return name && contentLower.includes(name);
|
||||
})
|
||||
.map(f => f.path || f);
|
||||
.map((f) => f.path || f);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
assistantMsg.content = `⚠ Error: ${e.message}`;
|
||||
console.warn('BooksLM chat error:', e);
|
||||
console.warn('AI assistant chat error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,23 +714,19 @@ class BooksLM {
|
||||
|
||||
_renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Escape HTML first so model output cannot inject markup.
|
||||
let html = text
|
||||
// Code blocks
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Bold
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
// Unordered lists
|
||||
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
||||
// Headers
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
// Paragraphs
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`;
|
||||
@@ -495,21 +735,22 @@ class BooksLM {
|
||||
// ── History persistence ─────────────────────────────────────────────
|
||||
|
||||
_historyKey() {
|
||||
return `bookslm-history-${this._vault}-${this._directory}`;
|
||||
const ctx = this._mode === MODE.DOCUMENTS
|
||||
? documentsSignature(this._documents)
|
||||
: (this._directory || this._vault || 'general');
|
||||
return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
|
||||
} catch {}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
_loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(this._historyKey());
|
||||
if (data) {
|
||||
this._messages = JSON.parse(data);
|
||||
}
|
||||
this._messages = data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
this._messages = [];
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ function getCMDS() {
|
||||
{ id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} },
|
||||
// BooksLM commands
|
||||
{ id:'bookslm-open', label:t('palette.bookslm_open'), desc:'Open BooksLM AI chat for current directory', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
|
||||
{ id:'bookslm-new', label:t('palette.bookslm_new'), desc:'Reset BooksLM conversation and open fresh', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.newConversation();m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
|
||||
// AI Assistant commands
|
||||
{ id:'bookslm-open', label:t('palette.bookslm_open'), desc:t('palette.bookslm_open_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.openForCurrentContext();} },
|
||||
{ id:'bookslm-new', label:t('palette.bookslm_new'), desc:t('palette.bookslm_new_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.newConversation();m.default.openForCurrentContext();} },
|
||||
];
|
||||
return _cmdsCache;
|
||||
}
|
||||
|
||||
@@ -78,8 +78,20 @@
|
||||
"admin.users_manage_btn": "Manage users",
|
||||
"admin.users_manage_hint": "Open the user management modal (full CRUD)",
|
||||
"admin.users_title": "User management",
|
||||
"ai.action_applied": "Applied ✓",
|
||||
"ai.action_apply": "Apply",
|
||||
"ai.action_applying": "Applying…",
|
||||
"ai.action_create_dir": "Create folder {path}",
|
||||
"ai.action_create_file": "Create file {path}",
|
||||
"ai.action_created_dir": "Folder created: {path}",
|
||||
"ai.action_created_file": "File created: {path}",
|
||||
"ai.action_failed": "Action failed: {error}",
|
||||
"ai.casual": "Casual tone",
|
||||
"ai.close": "Close assistant",
|
||||
"ai.completion_added": "AI: completion added",
|
||||
"ai.context_documents": "{count} open document(s)",
|
||||
"ai.context_general": "General context",
|
||||
"ai.context_general_desc": "Questions about the app and file creation",
|
||||
"ai.continue": "Continue",
|
||||
"ai.custom_rewrite": "Custom rewrite",
|
||||
"ai.done": "AI: text processed",
|
||||
@@ -89,6 +101,7 @@
|
||||
"ai.fix_spelling": "Fix spelling",
|
||||
"ai.fix_spelling_hint": "Fixes spelling and grammar",
|
||||
"ai.frontmatter": "Generate frontmatter",
|
||||
"ai.fullscreen": "Fullscreen",
|
||||
"ai.generate": "Generate",
|
||||
"ai.hint_add_details": "Adds details",
|
||||
"ai.hint_edit_selection": "Modifies the selected text",
|
||||
@@ -96,6 +109,7 @@
|
||||
"ai.hint_improve": "Improves text quality",
|
||||
"ai.hint_translate": "Translates the selected text",
|
||||
"ai.improve": "Improve",
|
||||
"ai.input_hint": "Enter to send · Ctrl+Enter for a line break",
|
||||
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Auto-complete text",
|
||||
"ai.invalid_api_key": "AI: invalid API key. Check DEEPSEEK_API_KEY in .env",
|
||||
"ai.longer": "Longer",
|
||||
@@ -110,6 +124,7 @@
|
||||
"ai.model_loading": "Loading…",
|
||||
"ai.model_offline": "offline",
|
||||
"ai.model_load_error": "Load error",
|
||||
"ai.model_settings": "Provider & model",
|
||||
"ai.quota_exceeded": "AI: quota exceeded or payment required",
|
||||
"ai.rewrite": "💬 Rewrite",
|
||||
"ai.rewrite_done": "AI: text rewritten",
|
||||
@@ -121,6 +136,12 @@
|
||||
"ai.shorter_hint": "Makes the text more concise",
|
||||
"ai.simplify": "Simplify",
|
||||
"ai.simplify_hint": "Simplifies the language",
|
||||
"ai.suggestion_capabilities": "What can you do?",
|
||||
"ai.suggestion_create_file": "Create a meeting notes file",
|
||||
"ai.suggestion_docs_contradictions": "Are there contradictions between these documents?",
|
||||
"ai.suggestion_docs_keypoints": "What are the key points of these documents?",
|
||||
"ai.suggestion_docs_summary": "Summarize the open documents",
|
||||
"ai.suggestion_search": "How do I search my notes effectively?",
|
||||
"ai.summarize": "Summarize",
|
||||
"ai.text_processed": "AI: text processed ✓",
|
||||
"ai.text_rewritten": "AI: text rewritten",
|
||||
@@ -1614,7 +1635,9 @@
|
||||
"bookslm.context_too_large": "Directory too large — some files were truncated",
|
||||
"bookslm.source": "Source",
|
||||
"palette.bookslm_open": "BooksLM: Open for current directory",
|
||||
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
|
||||
"palette.bookslm_new": "BooksLM: New conversation",
|
||||
"palette.bookslm_new_desc": "Resets the AI assistant conversation",
|
||||
"fab.open": "Open AI assistant",
|
||||
"fab.close": "Close AI assistant",
|
||||
"mfa.webauthn_title": "Security keys (WebAuthn)",
|
||||
|
||||
@@ -78,8 +78,20 @@
|
||||
"admin.users_manage_btn": "Gérer les utilisateurs",
|
||||
"admin.users_manage_hint": "Ouvrir la modale de gestion des utilisateurs (CRUD complet)",
|
||||
"admin.users_title": "Gestion utilisateurs",
|
||||
"ai.action_applied": "Appliqué ✓",
|
||||
"ai.action_apply": "Appliquer",
|
||||
"ai.action_applying": "Application…",
|
||||
"ai.action_create_dir": "Créer le dossier {path}",
|
||||
"ai.action_create_file": "Créer le fichier {path}",
|
||||
"ai.action_created_dir": "Dossier créé : {path}",
|
||||
"ai.action_created_file": "Fichier créé : {path}",
|
||||
"ai.action_failed": "Échec de l'action : {error}",
|
||||
"ai.casual": "Ton décontracté",
|
||||
"ai.close": "Fermer l'assistant",
|
||||
"ai.completion_added": "AI: complétion ajoutée",
|
||||
"ai.context_documents": "{count} document(s) ouvert(s)",
|
||||
"ai.context_general": "Contexte général",
|
||||
"ai.context_general_desc": "Questions sur l'application et création de fichiers",
|
||||
"ai.continue": "Continuer",
|
||||
"ai.custom_rewrite": "Réécriture personnalisée",
|
||||
"ai.done": "AI: texte traité",
|
||||
@@ -89,6 +101,7 @@
|
||||
"ai.fix_spelling": "Corriger l'orthographe",
|
||||
"ai.fix_spelling_hint": "Corrige les fautes",
|
||||
"ai.frontmatter": "Générer frontmatter",
|
||||
"ai.fullscreen": "Plein écran",
|
||||
"ai.generate": "Générer",
|
||||
"ai.hint_add_details": "Ajoute des détails",
|
||||
"ai.hint_edit_selection": "Modifie le texte sélectionné",
|
||||
@@ -96,6 +109,7 @@
|
||||
"ai.hint_improve": "Améliore la qualité du texte",
|
||||
"ai.hint_translate": "Traduit le texte sélectionné",
|
||||
"ai.improve": "Améliorer",
|
||||
"ai.input_hint": "Entrée pour envoyer · Ctrl+Entrée pour un saut de ligne",
|
||||
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Complète le texte automatiquement",
|
||||
"ai.invalid_api_key": "AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env",
|
||||
"ai.longer": "Allonger",
|
||||
@@ -110,6 +124,7 @@
|
||||
"ai.model_loading": "Chargement…",
|
||||
"ai.model_offline": "hors ligne",
|
||||
"ai.model_load_error": "Erreur de chargement",
|
||||
"ai.model_settings": "Fournisseur & modèle",
|
||||
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
|
||||
"ai.rewrite": "💬 Réécrire",
|
||||
"ai.rewrite_done": "AI: texte réécrit",
|
||||
@@ -121,6 +136,12 @@
|
||||
"ai.shorter_hint": "Rend le texte plus concis",
|
||||
"ai.simplify": "Simplifier",
|
||||
"ai.simplify_hint": "Simplifie le langage",
|
||||
"ai.suggestion_capabilities": "Que sais-tu faire ?",
|
||||
"ai.suggestion_create_file": "Crée un fichier de notes de réunion",
|
||||
"ai.suggestion_docs_contradictions": "Y a-t-il des contradictions entre ces documents ?",
|
||||
"ai.suggestion_docs_keypoints": "Quels sont les points clés de ces documents ?",
|
||||
"ai.suggestion_docs_summary": "Résume les documents ouverts",
|
||||
"ai.suggestion_search": "Comment rechercher efficacement dans mes notes ?",
|
||||
"ai.summarize": "Résumer",
|
||||
"ai.text_processed": "AI: texte traité ✓",
|
||||
"ai.text_rewritten": "AI: texte réécrit",
|
||||
@@ -1614,7 +1635,9 @@
|
||||
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
|
||||
"bookslm.source": "Source",
|
||||
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
|
||||
"palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
|
||||
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
|
||||
"palette.bookslm_new_desc": "Réinitialise la conversation de l'assistant AI",
|
||||
"fab.open": "Ouvrir l'assistant AI",
|
||||
"fab.close": "Fermer l'assistant AI",
|
||||
"mfa.webauthn_title": "Clés de sécurité (WebAuthn)",
|
||||
|
||||
+34
-8
@@ -9130,18 +9130,30 @@ body.popup-mode .content-area {
|
||||
}
|
||||
.bookslm-btn-toggle:hover { color: var(--accent); background: var(--surface2); }
|
||||
.bookslm-header { display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
font-size: 14px; font-weight: 600; }
|
||||
.bookslm-header .bookslm-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-header .bookslm-picker-host,
|
||||
.bookslm-header .ai-picker,
|
||||
.bookslm-header button { flex-shrink: 0; }
|
||||
.bookslm-header .ai-picker { z-index: 1; }
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.bookslm-header .bookslm-context-icon { display: flex; color: var(--accent); flex-shrink: 0; }
|
||||
.bookslm-header-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.bookslm-header-titles .bookslm-title { font-size: 13px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-header-titles .bookslm-subtitle { font-size: 11px; color: var(--text-secondary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.bookslm-header button { background: none; border: none; cursor: pointer;
|
||||
color: var(--text-secondary); font-size: 16px; padding: 4px 8px; border-radius: 4px; }
|
||||
color: var(--text-secondary); padding: 5px; border-radius: 6px; display: flex; align-items: center; }
|
||||
.bookslm-header button:hover { background: var(--surface2); color: var(--text-primary); }
|
||||
/* Provider/model picker on its own row so the header actions never get hidden. */
|
||||
.bookslm-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
|
||||
.bookslm-toolbar-label { font-size: 11px; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
||||
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
|
||||
flex-wrap: wrap; row-gap: 4px; }
|
||||
.bookslm-toolbar .ai-picker select { max-width: 200px; }
|
||||
.bookslm-panel.fullscreen { width: 100vw; }
|
||||
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
|
||||
.bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-status .bookslm-status-error { color: #f87171; }
|
||||
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
|
||||
background: var(--surface2); overflow: hidden; }
|
||||
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
|
||||
@@ -9165,6 +9177,20 @@ body.popup-mode .content-area {
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
|
||||
.bookslm-input-area button:hover { opacity: 0.9; }
|
||||
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
|
||||
text-align: right; border-top: none; }
|
||||
/* Action cards proposed by the General assistant (file/dir creation). */
|
||||
.bookslm-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.bookslm-action { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); }
|
||||
.bookslm-action.done { opacity: 0.65; }
|
||||
.bookslm-action-meta { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 12px;
|
||||
color: var(--text-primary); word-break: break-word; }
|
||||
.bookslm-action-icon { flex-shrink: 0; }
|
||||
.bookslm-action-apply { padding: 5px 12px; border-radius: 6px; border: none;
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
|
||||
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
|
||||
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
|
||||
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
|
||||
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
|
||||
|
||||
Reference in New Issue
Block a user