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:
+3
-2
@@ -4287,8 +4287,9 @@
|
||||
</li>
|
||||
<li data-i18n="help.assistant_images">
|
||||
Collez une image dans la zone de saisie (ou joignez-la) pour
|
||||
l'analyser avec un modèle compatible vision ; les capacités du
|
||||
modèle sélectionné sont affichées.
|
||||
l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche
|
||||
les capacités du modèle sélectionné, et la liste des modèles est
|
||||
filtrable par recherche.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
+239
-66
@@ -77,10 +77,44 @@ function renderCapabilityList(caps) {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Accent-insensitive, case-insensitive normalization for model search. */
|
||||
function _normalizeText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// One delegated document listener closes every picker popover when the user
|
||||
// clicks outside it. Bound once, whatever the number of pickers mounted.
|
||||
let _pickerOutsideBound = false;
|
||||
function _bindPickerOutside() {
|
||||
if (_pickerOutsideBound) return;
|
||||
_pickerOutsideBound = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.ai-picker-caps-host').forEach((host) => {
|
||||
if (host.contains(e.target)) return;
|
||||
const pop = host.querySelector('.ai-picker-caps-pop');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
});
|
||||
document.querySelectorAll('.ai-picker-model-combo').forEach((combo) => {
|
||||
if (combo.contains(e.target)) return;
|
||||
const pop = combo.querySelector('.ai-picker-model-pop');
|
||||
const trigger = combo.querySelector('.ai-picker-model-trigger');
|
||||
if (pop) pop.classList.add('hidden');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider+model picker that appears in every section's AI toolbar
|
||||
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
|
||||
* available providers, then /api/config/ai-models?provider=X to list models.
|
||||
*
|
||||
* Discreet layout: a compact provider select, a searchable model combobox and
|
||||
* an info button (ⓘ) that reveals the model capabilities in a bubble on hover,
|
||||
* click or long-press (mobile) instead of always listing every endpoint type.
|
||||
*/
|
||||
async function _buildPickerUI() {
|
||||
const pickerState = _readPicker();
|
||||
@@ -99,36 +133,14 @@ async function _buildPickerUI() {
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ai-picker';
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginLeft: '8px',
|
||||
paddingLeft: '8px',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-muted)',
|
||||
flexShrink: '0',
|
||||
zIndex: '1',
|
||||
});
|
||||
|
||||
// Provider select
|
||||
// ── Provider select (compact) ──
|
||||
const providerLabel = document.createElement('span');
|
||||
providerLabel.className = 'ai-picker-label';
|
||||
providerLabel.textContent = t('ai.provider') + ':';
|
||||
wrap.appendChild(providerLabel);
|
||||
|
||||
const providerSelect = document.createElement('select');
|
||||
providerSelect.className = 'ai-picker-select';
|
||||
Object.assign(providerSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = t('ai.provider_default');
|
||||
@@ -141,31 +153,178 @@ async function _buildPickerUI() {
|
||||
providerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Model select (sibling of provider)
|
||||
// Hidden native select kept as the source of truth so the assistant admin
|
||||
// commands (`/model`) and the stored picker state keep working unchanged.
|
||||
const modelSelect = document.createElement('select');
|
||||
modelSelect.className = 'ai-picker-model';
|
||||
Object.assign(modelSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '120px',
|
||||
maxWidth: '220px',
|
||||
});
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
placeholderOpt.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(placeholderOpt);
|
||||
modelSelect.hidden = true;
|
||||
|
||||
// Capability checklist, refreshed whenever the selected model changes.
|
||||
// ── Searchable model combobox ──
|
||||
const combo = document.createElement('div');
|
||||
combo.className = 'ai-picker-model-combo';
|
||||
|
||||
const modelTrigger = document.createElement('button');
|
||||
modelTrigger.type = 'button';
|
||||
modelTrigger.className = 'ai-picker-model-trigger';
|
||||
modelTrigger.setAttribute('aria-haspopup', 'listbox');
|
||||
modelTrigger.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const modelName = document.createElement('span');
|
||||
modelName.className = 'ai-picker-model-name';
|
||||
modelName.textContent = pickerState.model || t('ai.model_default');
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'ai-picker-model-chevron';
|
||||
chevron.textContent = '▾';
|
||||
modelTrigger.appendChild(modelName);
|
||||
modelTrigger.appendChild(chevron);
|
||||
|
||||
const modelPop = document.createElement('div');
|
||||
modelPop.className = 'ai-picker-model-pop hidden';
|
||||
|
||||
const modelSearch = document.createElement('input');
|
||||
modelSearch.type = 'text';
|
||||
modelSearch.className = 'ai-picker-model-search';
|
||||
modelSearch.autocomplete = 'off';
|
||||
modelSearch.placeholder = t('ai.model_search');
|
||||
modelSearch.setAttribute('aria-label', t('ai.model_search'));
|
||||
|
||||
const modelList = document.createElement('div');
|
||||
modelList.className = 'ai-picker-model-list';
|
||||
modelList.setAttribute('role', 'listbox');
|
||||
|
||||
modelPop.appendChild(modelSearch);
|
||||
modelPop.appendChild(modelList);
|
||||
combo.appendChild(modelTrigger);
|
||||
combo.appendChild(modelPop);
|
||||
|
||||
// ── Capabilities info button + bubble ──
|
||||
const capsHost = document.createElement('div');
|
||||
capsHost.className = 'ai-picker-caps-host';
|
||||
const infoBtn = document.createElement('button');
|
||||
infoBtn.type = 'button';
|
||||
infoBtn.className = 'ai-picker-info hidden';
|
||||
infoBtn.textContent = 'ℹ️';
|
||||
infoBtn.setAttribute('aria-label', t('ai.model_info'));
|
||||
infoBtn.title = t('ai.model_info');
|
||||
const capsPop = document.createElement('div');
|
||||
capsPop.className = 'ai-picker-caps-pop hidden';
|
||||
capsHost.appendChild(infoBtn);
|
||||
capsHost.appendChild(capsPop);
|
||||
|
||||
let _hideCapsTimer = null;
|
||||
let _longPressTimer = null;
|
||||
let _longPressFired = false;
|
||||
const showCaps = () => { clearTimeout(_hideCapsTimer); capsPop.classList.remove('hidden'); };
|
||||
const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); };
|
||||
infoBtn.addEventListener('mouseenter', showCaps);
|
||||
infoBtn.addEventListener('focus', showCaps);
|
||||
infoBtn.addEventListener('mouseleave', hideCaps);
|
||||
infoBtn.addEventListener('blur', hideCaps);
|
||||
infoBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// A long-press already opened the bubble: swallow the synthetic click.
|
||||
if (_longPressFired) { _longPressFired = false; return; }
|
||||
capsPop.classList.toggle('hidden');
|
||||
});
|
||||
capsPop.addEventListener('mouseenter', showCaps);
|
||||
capsPop.addEventListener('mouseleave', hideCaps);
|
||||
infoBtn.addEventListener('touchstart', () => {
|
||||
_longPressFired = false;
|
||||
_longPressTimer = setTimeout(() => { _longPressFired = true; showCaps(); }, 450);
|
||||
}, { passive: true });
|
||||
const _cancelLongPress = () => clearTimeout(_longPressTimer);
|
||||
infoBtn.addEventListener('touchend', _cancelLongPress);
|
||||
infoBtn.addEventListener('touchcancel', () => { _cancelLongPress(); _longPressFired = false; });
|
||||
_bindPickerOutside();
|
||||
|
||||
// ── Model list state ──
|
||||
let _models = [];
|
||||
let _activeIndex = -1;
|
||||
|
||||
const _visibleOptions = () => Array.from(modelList.querySelectorAll('.ai-picker-model-option'));
|
||||
|
||||
function _syncTrigger() {
|
||||
modelName.textContent = modelSelect.value || t('ai.model_default');
|
||||
}
|
||||
|
||||
function _highlight(index) {
|
||||
const options = _visibleOptions();
|
||||
if (!options.length) { _activeIndex = -1; return; }
|
||||
_activeIndex = ((index % options.length) + options.length) % options.length;
|
||||
options.forEach((el, i) => el.classList.toggle('active', i === _activeIndex));
|
||||
const active = options[_activeIndex];
|
||||
if (active && typeof active.scrollIntoView === 'function') {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function _renderList() {
|
||||
const q = _normalizeText(modelSearch.value);
|
||||
const matches = _models.filter((m) => !q || _normalizeText(m).includes(q));
|
||||
modelList.innerHTML = '';
|
||||
_activeIndex = -1;
|
||||
if (!matches.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ai-picker-model-empty';
|
||||
empty.textContent = t('ai.no_results');
|
||||
modelList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
matches.forEach((model) => {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'ai-picker-model-option';
|
||||
option.dataset.value = model;
|
||||
option.textContent = model || t('ai.model_default');
|
||||
if (model === modelSelect.value) option.classList.add('active');
|
||||
option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); });
|
||||
modelList.appendChild(option);
|
||||
});
|
||||
_activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value);
|
||||
}
|
||||
|
||||
function _pick(model) {
|
||||
modelSelect.value = model;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
_syncTrigger();
|
||||
_closePop();
|
||||
modelSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function _openPop() {
|
||||
modelPop.classList.remove('hidden');
|
||||
modelTrigger.setAttribute('aria-expanded', 'true');
|
||||
modelSearch.value = '';
|
||||
_renderList();
|
||||
modelSearch.focus();
|
||||
}
|
||||
function _closePop() {
|
||||
modelPop.classList.add('hidden');
|
||||
modelTrigger.setAttribute('aria-expanded', 'false');
|
||||
modelSearch.value = '';
|
||||
}
|
||||
|
||||
modelTrigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (modelPop.classList.contains('hidden')) _openPop();
|
||||
else _closePop();
|
||||
});
|
||||
modelSearch.addEventListener('input', _renderList);
|
||||
modelSearch.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _highlight(_activeIndex < 0 ? 0 : _activeIndex + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_activeIndex < 0 ? -1 : _activeIndex - 1); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const options = _visibleOptions();
|
||||
const chosen = options[_activeIndex] || options[0];
|
||||
if (chosen) _pick(chosen.dataset.value);
|
||||
} else if (e.key === 'Escape') { e.preventDefault(); _closePop(); }
|
||||
});
|
||||
|
||||
async function _updateCaps() {
|
||||
capsHost.innerHTML = '';
|
||||
capsPop.innerHTML = '';
|
||||
infoBtn.classList.add('hidden');
|
||||
if (!pickerState.provider || !pickerState.model) {
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
@@ -175,42 +334,58 @@ async function _buildPickerUI() {
|
||||
if (!caps) return;
|
||||
pickerState.capabilities = caps;
|
||||
_writePicker(pickerState);
|
||||
capsHost.appendChild(renderCapabilityList(caps));
|
||||
const title = document.createElement('div');
|
||||
title.className = 'ai-picker-caps-title';
|
||||
title.textContent = t('ai.model_info');
|
||||
capsPop.appendChild(title);
|
||||
capsPop.appendChild(renderCapabilityList(caps));
|
||||
infoBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function _loadModels(provider) {
|
||||
_models = [];
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_loading');
|
||||
modelSelect.appendChild(ph);
|
||||
_syncTrigger();
|
||||
if (!provider) {
|
||||
_renderList();
|
||||
await _updateCaps();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
|
||||
modelSelect.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
|
||||
def.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(def);
|
||||
_models = [''];
|
||||
(data.models || []).forEach((m) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
_models.push(m);
|
||||
});
|
||||
// Keep an explicitly stored model selectable even if the API omits it.
|
||||
if (pickerState.model && !_models.includes(pickerState.model)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = pickerState.model;
|
||||
opt.textContent = pickerState.model;
|
||||
modelSelect.appendChild(opt);
|
||||
_models.push(pickerState.model);
|
||||
}
|
||||
// If the provider default model is known, reflect it so capabilities
|
||||
// are shown even when the user keeps the "default" placeholder.
|
||||
if (!pickerState.model && data.models && data.models.length) {
|
||||
pickerState.model = data.models[0];
|
||||
_writePicker(pickerState);
|
||||
}
|
||||
modelSelect.value = pickerState.model || '';
|
||||
_syncTrigger();
|
||||
await _updateCaps();
|
||||
} catch {
|
||||
_models = [''];
|
||||
_syncTrigger();
|
||||
await _updateCaps();
|
||||
} catch (err) {
|
||||
modelSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = t('ai.model_load_error');
|
||||
modelSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,32 +395,30 @@ async function _buildPickerUI() {
|
||||
pickerState.model = null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
capsHost.innerHTML = '';
|
||||
if (pickerState.provider) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(ph);
|
||||
}
|
||||
_closePop();
|
||||
_loadModels(pickerState.provider);
|
||||
});
|
||||
|
||||
modelSelect.addEventListener('change', () => {
|
||||
pickerState.model = modelSelect.value || null;
|
||||
delete pickerState.capabilities;
|
||||
_writePicker(pickerState);
|
||||
_syncTrigger();
|
||||
_updateCaps();
|
||||
});
|
||||
|
||||
// Load models for the initial provider selection
|
||||
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
_syncTrigger();
|
||||
_renderList();
|
||||
}
|
||||
|
||||
wrap.appendChild(providerLabel);
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
wrap.appendChild(combo);
|
||||
wrap.appendChild(capsHost);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
+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');
|
||||
});
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"ai.model_offline": "offline",
|
||||
"ai.model_load_error": "Load error",
|
||||
"ai.model_settings": "Provider & model",
|
||||
"ai.model_search": "Search a model…",
|
||||
"ai.model_info": "Model capabilities",
|
||||
"ai.add_context": "Add context",
|
||||
"ai.attach_image": "Attach an image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
@@ -1269,7 +1271,7 @@
|
||||
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
|
||||
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
|
||||
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model; the selected model's capabilities are displayed.",
|
||||
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model. The ⓘ button shows the selected model's capabilities, and the model list is filterable by search.",
|
||||
"help.toolbar_section": "AI toolbar",
|
||||
"help.tree_section": "Vault tree",
|
||||
"help.use_cases": "Use cases",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"ai.model_offline": "hors ligne",
|
||||
"ai.model_load_error": "Erreur de chargement",
|
||||
"ai.model_settings": "Fournisseur & modèle",
|
||||
"ai.model_search": "Rechercher un modèle…",
|
||||
"ai.model_info": "Capacités du modèle",
|
||||
"ai.add_context": "Ajouter un contexte",
|
||||
"ai.attach_image": "Joindre une image",
|
||||
"ai.cap_audio_speech": "Audio Speech",
|
||||
@@ -1269,7 +1271,7 @@
|
||||
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
|
||||
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
|
||||
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
|
||||
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision ; les capacités du modèle sélectionné sont affichées.",
|
||||
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche les capacités du modèle sélectionné, et la liste des modèles est filtrable par recherche.",
|
||||
"help.toolbar_section": "Barre d'outils AI",
|
||||
"help.tree_section": "Arborescence des vaults",
|
||||
"help.use_cases": "Cas d'usage",
|
||||
|
||||
+41
-2
@@ -9362,8 +9362,47 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
|
||||
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
/* Model capability checklist in the picker. */
|
||||
.ai-picker-caps-host { flex-basis: 100%; }
|
||||
/* ── Provider/model picker (compact, shared by assistant + editor) ── */
|
||||
.ai-picker { display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.7rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
|
||||
.ai-picker-label { white-space: nowrap; }
|
||||
.ai-picker select { font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; cursor: pointer; }
|
||||
/* Searchable model combobox. */
|
||||
.ai-picker-model-combo { position: relative; display: inline-flex; }
|
||||
.ai-picker-model-trigger { display: inline-flex; align-items: center; gap: 4px; max-width: 220px;
|
||||
font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; cursor: pointer; }
|
||||
.ai-picker-model-trigger:hover { border-color: var(--accent); }
|
||||
.ai-picker-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.6rem; }
|
||||
.ai-picker-model-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60;
|
||||
min-width: 220px; max-width: 320px; background: var(--bg-primary);
|
||||
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
padding: 6px; }
|
||||
.ai-picker-model-pop.hidden { display: none; }
|
||||
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.72rem;
|
||||
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
|
||||
.ai-picker-model-list { max-height: 200px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.ai-picker-model-option { text-align: left; font-size: 0.72rem; padding: 5px 8px; border-radius: 5px;
|
||||
background: none; border: none; color: var(--text-primary); cursor: pointer;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-picker-model-option:hover, .ai-picker-model-option.active { background: var(--surface2); }
|
||||
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
|
||||
/* Capability info button (ⓘ) + informational bubble. */
|
||||
.ai-picker-caps-host { position: relative; display: inline-flex; }
|
||||
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
|
||||
font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 4px; }
|
||||
.ai-picker-info:hover, .ai-picker-info:focus { color: var(--accent); }
|
||||
.ai-picker-info.hidden { display: none; }
|
||||
.ai-picker-caps-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
|
||||
min-width: 220px; max-width: 300px; background: var(--bg-primary);
|
||||
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
padding: 8px 10px; }
|
||||
.ai-picker-caps-pop.hidden { display: none; }
|
||||
.ai-picker-caps-title { font-size: 0.7rem; font-weight: 600; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
|
||||
.ai-picker-caps { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; font-size: 10px;
|
||||
color: var(--text-secondary); }
|
||||
.ai-cap-item { white-space: nowrap; opacity: 0.65; }
|
||||
|
||||
Reference in New Issue
Block a user