feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
Refonte de la section Fournisseur & modele de l'assistant : capacites regroupees dans une bulle d'information (survol/clic/appui long) au lieu d'une liste permanente, et liste de modeles avec recherche. Correctifs : detection Unicode + filtrage insensible aux accents des commandes / et mentions @, navigation clavier fiable, et prise en compte du contexte ad-hoc en mode General cote backend.
This commit is contained in:
+55
-19
@@ -30,6 +30,18 @@ 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).
|
||||
@@ -93,6 +105,8 @@ class BooksLM {
|
||||
this._menuItems = [];
|
||||
this._menuIndex = 0;
|
||||
this._menuKind = null;
|
||||
// Monotonic token used to drop stale async menu renders (fast typing).
|
||||
this._menuSeq = 0;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
@@ -727,12 +741,13 @@ class BooksLM {
|
||||
if (!textarea) return;
|
||||
const pos = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, pos);
|
||||
const command = before.match(/(?:^|\s)\/([a-z0-9-]*)$/i);
|
||||
// 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].toLowerCase());
|
||||
this._showCommandMenu(command[1]);
|
||||
return;
|
||||
}
|
||||
const mention = before.match(/(?:^|\s)@([\w./-]*)$/);
|
||||
const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u);
|
||||
if (mention) {
|
||||
this._showMentionMenu(mention[1]);
|
||||
return;
|
||||
@@ -758,10 +773,12 @@ class BooksLM {
|
||||
|
||||
async _showCommandMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const seq = ++this._menuSeq;
|
||||
const skills = await this._ensureSkills();
|
||||
const q = (query || '').toLowerCase();
|
||||
if (seq !== this._menuSeq) return; // a newer input superseded this render
|
||||
const q = normalizeSearch(query);
|
||||
const matches = skills.filter((s) =>
|
||||
!q || s.id.includes(q) || (s.label || '').toLowerCase().includes(q));
|
||||
!q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q));
|
||||
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
|
||||
}
|
||||
|
||||
@@ -771,18 +788,18 @@ class BooksLM {
|
||||
if (skill.type === 'admin') {
|
||||
this._runAdminCommand(skill.id);
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
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)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
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)\/[a-z0-9-]*$/i, '$1').trim();
|
||||
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
this._renderAttachments();
|
||||
@@ -790,16 +807,19 @@ class BooksLM {
|
||||
|
||||
async _showMentionMenu(query) {
|
||||
if (!this._panel) return;
|
||||
const seq = ++this._menuSeq;
|
||||
const vault = this._resolveVault();
|
||||
if (!vault) {
|
||||
if (!vault && !query) {
|
||||
// Nothing to list without a vault; a typed query can still search all.
|
||||
this._hideMenus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let results = [];
|
||||
if (query) {
|
||||
const searchVault = vault || 'all';
|
||||
const data = await api(
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vault)}`
|
||||
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
|
||||
);
|
||||
results = (data.results || []).slice(0, 20);
|
||||
} else {
|
||||
@@ -808,13 +828,16 @@ class BooksLM {
|
||||
);
|
||||
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file' }));
|
||||
}
|
||||
const items = results.map((r) => ({
|
||||
id: r.path || r,
|
||||
label: r.path || r,
|
||||
type: r.type || (this._classifyPath(r.path || r) === 'dir' ? 'dir' : 'file'),
|
||||
}));
|
||||
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 };
|
||||
});
|
||||
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();
|
||||
}
|
||||
@@ -824,7 +847,7 @@ class BooksLM {
|
||||
const textarea = this._panel && this._panel.querySelector('textarea');
|
||||
this._hideMenus();
|
||||
if (textarea) {
|
||||
textarea.value = textarea.value.replace(/(^|\s)@[\w./-]*$/, '$1').trim();
|
||||
textarea.value = textarea.value.replace(/(^|\s)@[\p{L}\p{N}._/-]*$/u, '$1').trim();
|
||||
textarea.focus();
|
||||
}
|
||||
const path = item.id;
|
||||
@@ -864,6 +887,8 @@ class BooksLM {
|
||||
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' ? '⚙️' : '📄');
|
||||
@@ -901,10 +926,19 @@ class BooksLM {
|
||||
if (!this._panel || !this._menuKind) return;
|
||||
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
|
||||
if (!menu) return;
|
||||
this._menuIndex = index;
|
||||
menu.querySelectorAll('.bookslm-menu-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === index);
|
||||
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) {
|
||||
@@ -938,6 +972,8 @@ class BooksLM {
|
||||
|
||||
_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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user