feat: assistant IA - actions instantanées contextuelles, catalogue Toutes les actions & frontmatter complet #106
This commit is contained in:
+253
-34
@@ -10,6 +10,15 @@ import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
// #106 — contextual quick actions (catalogue + triage rules live in ai-quick-actions.js).
|
||||
import {
|
||||
detectContext,
|
||||
suggestionsFor,
|
||||
actionTexts,
|
||||
contextBadgeKey,
|
||||
ACTION_CATALOG,
|
||||
CATEGORIES,
|
||||
} from './ai-quick-actions.js';
|
||||
|
||||
export const MODE = Object.freeze({
|
||||
DIRECTORY: 'directory',
|
||||
@@ -920,6 +929,7 @@ class BooksLM {
|
||||
<span class="bookslm-title"></span>
|
||||
<span class="bookslm-subtitle"></span>
|
||||
</div>
|
||||
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
|
||||
<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>
|
||||
@@ -1071,6 +1081,25 @@ class BooksLM {
|
||||
});
|
||||
textarea.addEventListener('paste', (e) => this._onPaste(e));
|
||||
|
||||
// #106 — The suggested row flips to "selection" actions while the user
|
||||
// selects text in the open editor; refresh (throttled) on selectionchange.
|
||||
let qaSelTimer = null;
|
||||
const onSelectionChange = () => {
|
||||
if (qaSelTimer) return;
|
||||
qaSelTimer = setTimeout(() => {
|
||||
qaSelTimer = null;
|
||||
if (!this._panel || this._panel.classList.contains('hidden')) return;
|
||||
if (this._messages.length) return;
|
||||
const ctx = this._quickContext();
|
||||
if (ctx !== this._qaContext) this._showSuggestions();
|
||||
}, 250);
|
||||
};
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
// CodeMirror owns its selection (no native selectionchange on drag), so
|
||||
// the same throttled refresh is bound to pointer/keyboard release too.
|
||||
document.addEventListener('mouseup', onSelectionChange);
|
||||
document.addEventListener('keyup', onSelectionChange);
|
||||
|
||||
// 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
|
||||
@@ -2015,26 +2044,52 @@ class BooksLM {
|
||||
}
|
||||
}
|
||||
|
||||
_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'),
|
||||
];
|
||||
// ── #106 — Actions instantanées contextuelles ───────────────────────
|
||||
//
|
||||
// The welcome zone above the composer shows the 3 top actions for the
|
||||
// *detected* context (selection > code > multi-doc > single-doc >
|
||||
// directory > general, rules in ai-quick-actions.js); the full catalogue
|
||||
// stays reachable through the "Toutes les actions" drawer.
|
||||
|
||||
/** True when the open editor (CM6, or the mobile textarea fallback)
|
||||
* holds a non-empty text selection. Best-effort, never throws. */
|
||||
_hasEditorSelection() {
|
||||
try {
|
||||
const view = state.editorView;
|
||||
if (view && view.state && view.state.selection) {
|
||||
const sel = view.state.selection.main;
|
||||
if (sel && sel.from !== sel.to) return true;
|
||||
}
|
||||
const ta = state.fallbackEditorEl;
|
||||
if (ta && document.body.contains(ta)
|
||||
&& ta.selectionStart != null && ta.selectionEnd > ta.selectionStart) {
|
||||
return true;
|
||||
}
|
||||
} catch { /* editor not ready */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The live context key driving the suggested-actions row. */
|
||||
_quickContext() {
|
||||
const docs = this._mode === MODE.DOCUMENTS ? this._documents : [];
|
||||
return detectContext({
|
||||
mode: this._mode,
|
||||
docCount: docs.length,
|
||||
currentPath: docs.length ? (docs[0].path || null) : null,
|
||||
hasSelection: this._hasEditorSelection(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Header chip describing the detected context ("1 doc ouvert", …). */
|
||||
_updateQaBadge() {
|
||||
if (!this._panel) return;
|
||||
const badge = this._panel.querySelector('.bookslm-qa-badge');
|
||||
if (!badge) return;
|
||||
const ctx = this._qaContext || this._quickContext();
|
||||
const docCount = this._mode === MODE.DOCUMENTS ? this._documents.length : 0;
|
||||
badge.textContent = t(contextBadgeKey(ctx, docCount), { count: docCount });
|
||||
badge.dataset.qaContext = ctx;
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
|
||||
_showSuggestions() {
|
||||
@@ -2042,21 +2097,185 @@ class BooksLM {
|
||||
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);
|
||||
if (this._messages.length) {
|
||||
sugEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
sugEl.classList.remove('hidden');
|
||||
const ctx = this._quickContext();
|
||||
this._qaContext = ctx;
|
||||
this._updateQaBadge();
|
||||
|
||||
// Welcome hint (mirrors the POC copy: actions or free question).
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'bookslm-qa-hint';
|
||||
hint.textContent = t('qa.empty_hint');
|
||||
sugEl.appendChild(hint);
|
||||
|
||||
// Row head: label + access to the full catalogue.
|
||||
const head = document.createElement('div');
|
||||
head.className = 'bookslm-qa-head';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'bookslm-qa-label';
|
||||
label.textContent = t('qa.header_suggested');
|
||||
head.appendChild(label);
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'bookslm-qa-more';
|
||||
more.innerHTML = `<span>${t('qa.all_actions')}</span>`
|
||||
+ '<i data-lucide="layout-grid" style="width:13px;height:13px"></i>';
|
||||
more.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleActionDrawer();
|
||||
});
|
||||
head.appendChild(more);
|
||||
sugEl.appendChild(head);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'bookslm-qa-list';
|
||||
for (const action of suggestionsFor(ctx)) {
|
||||
list.appendChild(this._renderQuickActionBtn(action));
|
||||
}
|
||||
sugEl.appendChild(list);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
/** One action button (icon + label + hover arrow), used in both the row
|
||||
* and the drawer catalogue. */
|
||||
_renderQuickActionBtn(action, opts = {}) {
|
||||
const { label, prompt } = actionTexts(action);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bookslm-qa-btn' + (opts.compact ? ' compact' : '');
|
||||
btn.dataset.qaAction = action.id;
|
||||
btn.innerHTML = `
|
||||
<i data-lucide="${action.icon}" class="bookslm-qa-icon" style="width:15px;height:15px"></i>
|
||||
<span class="bookslm-qa-text">${label}</span>
|
||||
<i data-lucide="arrow-right" class="bookslm-qa-go" style="width:14px;height:14px"></i>
|
||||
`;
|
||||
btn.title = prompt;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._runQuickAction(action);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Immediate-send the action prompt through the composer (same path as a
|
||||
* typed message: skills, agent mode and images all keep working). Actions
|
||||
* flagged `agent` (they mutate the document) transparently switch the
|
||||
* assistant to agent mode first — same pattern as Deep Research. */
|
||||
_runQuickAction(action) {
|
||||
if (!this._panel || !action) return;
|
||||
const { prompt } = actionTexts(action);
|
||||
if (!prompt) return;
|
||||
this._closeActionDrawer();
|
||||
if (action.agent && !this._agentMode) this._toggleAgentMode();
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
textarea.value = prompt;
|
||||
this._sendMessage();
|
||||
}
|
||||
|
||||
// ── Drawer "Toutes les actions" (bottom sheet, searchable) ──────────
|
||||
|
||||
_toggleActionDrawer() {
|
||||
const drawer = this._ensureActionDrawer();
|
||||
if (!drawer) return;
|
||||
if (drawer.classList.contains('open')) this._closeActionDrawer();
|
||||
else this._openActionDrawer();
|
||||
}
|
||||
|
||||
_ensureActionDrawer() {
|
||||
if (!this._panel) return null;
|
||||
let drawer = this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (drawer) return drawer;
|
||||
drawer = document.createElement('div');
|
||||
drawer.className = 'bookslm-qa-drawer hidden';
|
||||
drawer.innerHTML = `
|
||||
<div class="bookslm-qa-sheet" role="dialog" aria-modal="true" aria-label="${t('qa.drawer_title')}">
|
||||
<div class="bookslm-qa-sheet-head">
|
||||
<span class="bookslm-qa-sheet-title">
|
||||
<i data-lucide="layout-grid" style="width:15px;height:15px"></i>
|
||||
${t('qa.drawer_title')}
|
||||
</span>
|
||||
<button type="button" class="bookslm-qa-sheet-close" aria-label="${t('ai.close')}">
|
||||
<i data-lucide="x" style="width:15px;height:15px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bookslm-qa-search">
|
||||
<i data-lucide="search" style="width:14px;height:14px"></i>
|
||||
<input type="text" placeholder="${t('qa.search_placeholder')}" aria-label="${t('qa.search_placeholder')}">
|
||||
</div>
|
||||
<div class="bookslm-qa-sheet-list"></div>
|
||||
</div>
|
||||
`;
|
||||
this._panel.appendChild(drawer);
|
||||
drawer.querySelector('.bookslm-qa-sheet-close').addEventListener('click', () => this._closeActionDrawer());
|
||||
// Clicking the backdrop (outside the sheet) closes the drawer.
|
||||
drawer.addEventListener('click', (e) => {
|
||||
if (e.target === drawer) this._closeActionDrawer();
|
||||
});
|
||||
const input = drawer.querySelector('.bookslm-qa-search input');
|
||||
input.addEventListener('input', () => this._renderActionDrawerList(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._closeActionDrawer();
|
||||
}
|
||||
});
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
return drawer;
|
||||
}
|
||||
|
||||
_openActionDrawer() {
|
||||
const drawer = this._ensureActionDrawer();
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('hidden');
|
||||
requestAnimationFrame(() => drawer.classList.add('open'));
|
||||
this._renderActionDrawerList('');
|
||||
const input = drawer.querySelector('.bookslm-qa-search input');
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
}
|
||||
|
||||
_closeActionDrawer() {
|
||||
if (!this._panel) return;
|
||||
const drawer = this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('open');
|
||||
// Let the slide-down transition finish before hiding.
|
||||
setTimeout(() => drawer.classList.add('hidden'), 220);
|
||||
}
|
||||
|
||||
_renderActionDrawerList(filter) {
|
||||
const drawer = this._panel && this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (!drawer) return;
|
||||
const body = drawer.querySelector('.bookslm-qa-sheet-list');
|
||||
if (!body) return;
|
||||
body.innerHTML = '';
|
||||
const q = normalizeSearch(filter || '');
|
||||
let shown = 0;
|
||||
for (const cat of CATEGORIES) {
|
||||
const actions = ACTION_CATALOG.filter((a) => a.cat === cat.id
|
||||
&& (!q || normalizeSearch(actionTexts(a).label).includes(q)));
|
||||
if (!actions.length) continue;
|
||||
const head = document.createElement('p');
|
||||
head.className = 'bookslm-qa-cat';
|
||||
head.innerHTML = `<i data-lucide="${cat.icon}" style="width:13px;height:13px"></i> ${t(cat.labelKey)}`;
|
||||
body.appendChild(head);
|
||||
for (const action of actions) {
|
||||
body.appendChild(this._renderQuickActionBtn(action, { compact: true }));
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'bookslm-qa-empty';
|
||||
empty.textContent = t('qa.no_match');
|
||||
body.appendChild(empty);
|
||||
}
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
_renderMessages(opts = {}) {
|
||||
|
||||
Reference in New Issue
Block a user