feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m33s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m32s

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:
2026-09-12 12:40:03 -04:00
parent f049e208b6
commit d6b7c0e9cb
14 changed files with 643 additions and 93 deletions
+239 -66
View File
@@ -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;
}