feat(ai): sidebar epuree, suivi visuel des requetes et correctif menu @ (BUG-012, #82)
CI / lint (push) Successful in 1m9s
CI / security (push) Successful in 44s
CI / test (push) Successful in 2m29s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m39s

BUG-012: le menu @ est toujours rendu et _mentionVault() retombe sur le vault de contexte puis le premier vault disponible. #82: suppression des intitules Fournisseur & modele / Fournisseur:, description du contexte General en info-bulle, placeholder retire, bouton Envoyer en emoji, indicateur d'activite (envoi/reception/outils/confirmation/succes/echec), SW_VERSION v5.
This commit is contained in:
2026-09-12 14:21:33 -04:00
parent eea2108ac2
commit ee4c273d73
10 changed files with 190 additions and 28 deletions
+2 -6
View File
@@ -139,13 +139,10 @@ async function _buildPickerUI() {
const wrap = document.createElement('div');
wrap.className = 'ai-picker';
// ── Provider select (compact) ──
const providerLabel = document.createElement('span');
providerLabel.className = 'ai-picker-label';
providerLabel.textContent = t('ai.provider') + ':';
// ── Provider select (compact, no label to keep the toolbar discreet) ──
const providerSelect = document.createElement('select');
providerSelect.className = 'ai-picker-select';
providerSelect.setAttribute('aria-label', t('ai.provider'));
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('ai.provider_default');
@@ -445,7 +442,6 @@ async function _buildPickerUI() {
_renderList();
}
wrap.appendChild(providerLabel);
wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect);
wrap.appendChild(combo);
+79 -18
View File
@@ -107,6 +107,8 @@ class BooksLM {
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;
}
_readAgentMode() {
@@ -333,6 +335,20 @@ class BooksLM {
return this._resolveVault();
}
/**
* Concrete vault used to browse files in the `@` mention menu. Falls back to
* the sidebar's selected context vault (then the first vault) so the menu can
* list files even in General mode where no vault is otherwise resolved.
*/
_mentionVault() {
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;
}
/** Open a file mentioned in an assistant answer in the main viewer. */
async _openFileLink(path) {
const vault = this._resolveVault();
@@ -433,7 +449,9 @@ class BooksLM {
return {
icon: 'sparkles',
title: t('ai.context_general'),
subtitle: t('ai.context_general_desc'),
// The description is kept for the tooltip only, to keep the header clean.
subtitle: '',
tooltip: t('ai.context_general_desc'),
};
}
@@ -468,12 +486,15 @@ class BooksLM {
</div>
<div class="bookslm-history-menu hidden"></div>
<div class="bookslm-toolbar">
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
<span class="bookslm-picker-host"></span>
</div>
<div class="bookslm-status"></div>
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<div class="bookslm-activity hidden" role="status" aria-live="polite">
<span class="bookslm-activity-spinner" aria-hidden="true"></span>
<span class="bookslm-activity-text"></span>
</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>
@@ -482,8 +503,8 @@ class BooksLM {
<div class="bookslm-input-area">
<button class="bookslm-btn-attach" title="${t('ai.attach_image')}" aria-label="${t('ai.attach_image')}"><i data-lucide="image-plus" style="width:18px;height:18px"></i></button>
<input type="file" class="bookslm-file-input" accept="image/*" multiple hidden>
<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>
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('ai.composer_label')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}" aria-label="${t('bookslm.send')}">✈️</button>
</div>
<div class="bookslm-input-hint">${t('ai.input_hint')}</div>
`;
@@ -820,12 +841,7 @@ class BooksLM {
async _showMentionMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const vault = this._contextVault();
if (!vault && !query) {
// Nothing to list without a vault; a typed query can still search all.
this._hideMenus();
return;
}
const vault = this._mentionVault();
try {
let results = [];
if (query) {
@@ -834,24 +850,25 @@ class BooksLM {
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
);
results = (data.results || []).slice(0, 20);
} else {
} 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' }));
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 || null };
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._hideMenus();
this._renderMenu('mention', [], () => {});
}
}
@@ -1185,37 +1202,68 @@ class BooksLM {
_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();
}
/**
* Show the assistant activity state in the panel (visual workflow).
* @param {'idle'|'working'|'done'|'error'} kind
* @param {string} [text]
*/
_setActivity(kind, text) {
if (!this._panel) return;
if (this._activityHideTimer) {
clearTimeout(this._activityHideTimer);
this._activityHideTimer = null;
}
const el = this._panel.querySelector('.bookslm-activity');
if (!el) return;
const label = el.querySelector('.bookslm-activity-text');
if (label) label.textContent = text || '';
el.classList.toggle('hidden', kind === 'idle');
el.classList.toggle('working', kind === 'working');
el.classList.toggle('done', kind === 'done');
el.classList.toggle('error', kind === 'error');
}
_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 = data.scope === MODE.GENERAL
? t('ai.context_general_desc')
: `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
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 = '⏳ …';
}
}
@@ -1630,6 +1678,7 @@ class BooksLM {
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
this._setActivity('working', t('ai.activity_sending'));
try {
let resp = await this._postChat(payload);
@@ -1643,6 +1692,7 @@ class BooksLM {
throw new Error(detail);
}
this._setActivity('working', t('ai.activity_streaming'));
await this._streamResponse(resp, assistantMsg, payload);
if (!assistantMsg.sources.length && this._contextFiles.length) {
@@ -1654,10 +1704,15 @@ class BooksLM {
})
.map((f) => f.path || f);
}
this._setActivity('done', t('ai.activity_done'));
this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500);
} 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');
}
}
@@ -1714,6 +1769,7 @@ class BooksLM {
if (currentEvent === 'tool') {
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}
if (currentEvent === 'confirmation') {
@@ -1721,15 +1777,20 @@ class BooksLM {
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 || '';
if (chunk) assistantMsg.content += chunk;
if (chunk) {
if (!assistantMsg.content) this._setActivity('working', t('ai.activity_streaming'));
assistantMsg.content += chunk;
}
if (data.sources) assistantMsg.sources = data.sources;
};