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:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user