Add searchable benchmark voice pickers
This commit is contained in:
parent
f469d428e2
commit
7b855394a5
269
static/js/benchmark-voice-picker.js
Normal file
269
static/js/benchmark-voice-picker.js
Normal file
@ -0,0 +1,269 @@
|
||||
// ── Benchmark Voice Picker ───────────────────────────────────────────────
|
||||
// Searchable benchmark voice selector with avatars plus duration/speed stats.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const pickers = {};
|
||||
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function norm(value) {
|
||||
if (typeof value === 'string') return { id: value, label: value, meta: null, raw: value };
|
||||
const id = value?.id || value?.voice || value?.name || value?.voice_id || '';
|
||||
const hasExplicitMeta = Object.prototype.hasOwnProperty.call(value || {}, 'meta');
|
||||
const looksLikeVoice = !!(value?.display_name || value?.duration || value?.benchmark || value?.has_picture || value?.transcript);
|
||||
return {
|
||||
id,
|
||||
label: value?.label || value?.display_name || value?.name || value?.voice || id,
|
||||
meta: hasExplicitMeta ? value.meta : (looksLikeVoice ? value : null),
|
||||
raw: value,
|
||||
};
|
||||
}
|
||||
|
||||
function libraryVoice(id) {
|
||||
return (window._voices || []).find(v => v && v.id === id) || null;
|
||||
}
|
||||
|
||||
function metaFor(item) {
|
||||
return item?.meta || libraryVoice(item?.id) || item?.raw || null;
|
||||
}
|
||||
|
||||
function langColor(lang, id) {
|
||||
const str = String(lang || id || '').toLowerCase();
|
||||
if (str.startsWith('de')) return '#3b82f6';
|
||||
if (str.startsWith('en')) return '#10b981';
|
||||
if (str.startsWith('fr')) return '#8b5cf6';
|
||||
if (str.startsWith('es')) return '#f59e0b';
|
||||
if (str.startsWith('it')) return '#ef4444';
|
||||
if (str.startsWith('zh')) return '#ec4899';
|
||||
if (str.startsWith('ja')) return '#f97316';
|
||||
const palette = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16'];
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
function avatarHtml(item, size) {
|
||||
const id = item?.id || '';
|
||||
const meta = metaFor(item);
|
||||
const s = `${size}px`;
|
||||
const r = `${Math.round(size / 2)}px`;
|
||||
if (meta?.has_picture) {
|
||||
return `<img src="/api/voice/picture/${encodeURIComponent(id)}" class="bvp-avatar" style="width:${s};height:${s};border-radius:${r};object-fit:cover" alt="">`;
|
||||
}
|
||||
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta?.avatar, size) : null;
|
||||
if (icon) return icon.replace(/vp-avatar/g, 'bvp-avatar');
|
||||
const color = langColor(meta?.lang || meta?.language, id);
|
||||
const init = (item?.label || id || '?').trim()[0]?.toUpperCase() || '?';
|
||||
return `<span class="bvp-avatar" style="width:${s};height:${s};border-radius:${r};background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.44)}px">${esc(init)}</span>`;
|
||||
}
|
||||
|
||||
function numeric(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function statsFor(item) {
|
||||
const meta = metaFor(item) || {};
|
||||
const b = meta.benchmark || item?.benchmark || {};
|
||||
const parts = [];
|
||||
const duration = numeric(meta.duration ?? item?.duration);
|
||||
const speed = numeric(b.speed ?? b.avg_speed ?? meta.speed);
|
||||
const rtf = numeric(b.rtf ?? b.avg_rtf ?? meta.rtf);
|
||||
const elapsed = numeric(b.elapsed_sec ?? b.avg_elapsed_sec);
|
||||
if (duration != null && duration > 0) parts.push(`${duration.toFixed(1)}s`);
|
||||
if (speed != null && speed > 0) parts.push(`${speed.toFixed(2)}x`);
|
||||
else if (rtf != null && rtf > 0) parts.push(`RTF ${rtf.toFixed(2)}`);
|
||||
if (!parts.length && elapsed != null && elapsed > 0) parts.push(`${elapsed.toFixed(1)}s TTS`);
|
||||
if (!parts.length && b.ok === false) parts.push('benchmark failed');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function searchText(item) {
|
||||
const meta = metaFor(item) || {};
|
||||
return [
|
||||
item?.id,
|
||||
item?.label,
|
||||
meta.display_name,
|
||||
meta.name,
|
||||
meta.lang,
|
||||
meta.language,
|
||||
Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags,
|
||||
statsFor(item),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
function optionLabel(item) {
|
||||
const stats = statsFor(item);
|
||||
return `${item.label || item.id}${stats ? ` (${stats})` : ''}`;
|
||||
}
|
||||
|
||||
function renderTrigger(picker) {
|
||||
const value = picker.select.value;
|
||||
const item = picker.items.find(v => v.id === value);
|
||||
const content = picker.trigger.querySelector('.bvp-trigger-content');
|
||||
if (!item) {
|
||||
content.innerHTML = `<span class="bvp-placeholder">${esc(picker.placeholder)}</span>`;
|
||||
return;
|
||||
}
|
||||
const stats = statsFor(item);
|
||||
content.innerHTML = `${avatarHtml(item, 24)}<span class="bvp-trigger-main"><span class="bvp-trigger-name">${esc(item.label || item.id)}</span>${stats ? `<span class="bvp-trigger-stats">${esc(stats)}</span>` : ''}</span>`;
|
||||
}
|
||||
|
||||
function renderList(picker, filter) {
|
||||
const q = String(filter || '').trim().toLowerCase();
|
||||
const items = q ? picker.items.filter(item => searchText(item).includes(q)) : picker.items;
|
||||
if (!items.length) {
|
||||
picker.list.innerHTML = `<div class="bvp-empty">${esc(q ? 'No voices found' : picker.empty)}</div>`;
|
||||
return;
|
||||
}
|
||||
picker.list.innerHTML = items.map(item => {
|
||||
const meta = metaFor(item) || {};
|
||||
const stats = statsFor(item);
|
||||
const sub = [meta.lang || meta.language, meta.file_type].filter(Boolean).join(' · ');
|
||||
return `<button class="bvp-item${item.id === picker.select.value ? ' selected' : ''}" type="button" data-value="${esc(item.id)}">
|
||||
${avatarHtml(item, 28)}
|
||||
<span class="bvp-item-main"><span class="bvp-item-name">${esc(item.label || item.id)}</span>${sub ? `<span class="bvp-item-sub">${esc(sub)}</span>` : ''}</span>
|
||||
${stats ? `<span class="bvp-item-stats">${esc(stats)}</span>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function close(picker) {
|
||||
if (!picker) return;
|
||||
picker.drop.hidden = true;
|
||||
picker.drop.style.display = 'none';
|
||||
picker.trigger.classList.remove('open');
|
||||
}
|
||||
|
||||
function closeAll(except) {
|
||||
Object.values(pickers).forEach(p => { if (p !== except) close(p); });
|
||||
}
|
||||
|
||||
function open(picker) {
|
||||
closeAll(picker);
|
||||
picker.drop.hidden = false;
|
||||
picker.drop.style.display = '';
|
||||
picker.trigger.classList.add('open');
|
||||
picker.search.value = '';
|
||||
renderList(picker, '');
|
||||
picker.search.focus();
|
||||
requestAnimationFrame(() => picker.list.querySelector('.bvp-item.selected')?.scrollIntoView({ block: 'nearest' }));
|
||||
}
|
||||
|
||||
function setValue(picker, value, dispatch) {
|
||||
picker.select.value = value || '';
|
||||
renderTrigger(picker);
|
||||
renderList(picker, picker.search.value);
|
||||
close(picker);
|
||||
if (dispatch !== false) picker.select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
picker.trigger.focus();
|
||||
}
|
||||
|
||||
function upgrade(selectId, options) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return null;
|
||||
if (pickers[selectId]) return pickers[selectId];
|
||||
const picker = {
|
||||
select,
|
||||
items: [],
|
||||
placeholder: options?.placeholder || Array.from(select.options).find(o => !o.value)?.textContent || '— select after fetch —',
|
||||
empty: options?.empty || 'No voices',
|
||||
};
|
||||
select.dataset.bvpUpgraded = '1';
|
||||
select.style.display = 'none';
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'bvp-root';
|
||||
root.dataset.bvpFor = selectId;
|
||||
select.parentNode.insertBefore(root, select);
|
||||
|
||||
root.innerHTML = `<button class="bvp-trigger" type="button">
|
||||
<span class="bvp-trigger-content"><span class="bvp-placeholder">${esc(picker.placeholder)}</span></span>
|
||||
<span class="mdi mdi-chevron-down bvp-chevron"></span>
|
||||
</button>
|
||||
<div class="bvp-drop" hidden>
|
||||
<div class="bvp-search-wrap"><span class="mdi mdi-magnify bvp-search-icon"></span><input class="bvp-search" type="search" placeholder="Search voices..." autocomplete="off" spellcheck="false"></div>
|
||||
<div class="bvp-list"></div>
|
||||
</div>`;
|
||||
|
||||
Object.assign(picker, {
|
||||
root,
|
||||
trigger: root.querySelector('.bvp-trigger'),
|
||||
drop: root.querySelector('.bvp-drop'),
|
||||
search: root.querySelector('.bvp-search'),
|
||||
list: root.querySelector('.bvp-list'),
|
||||
});
|
||||
|
||||
picker.trigger.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
picker.drop.hidden ? open(picker) : close(picker);
|
||||
});
|
||||
picker.trigger.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(picker); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); open(picker); picker.list.querySelector('.bvp-item')?.focus(); }
|
||||
});
|
||||
picker.search.addEventListener('input', () => renderList(picker, picker.search.value));
|
||||
picker.search.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(picker); picker.trigger.focus(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); picker.list.querySelector('.bvp-item')?.focus(); }
|
||||
});
|
||||
picker.list.addEventListener('click', e => {
|
||||
const item = e.target.closest('.bvp-item');
|
||||
if (item) setValue(picker, item.dataset.value, true);
|
||||
});
|
||||
picker.list.addEventListener('keydown', e => {
|
||||
const item = e.target.closest('.bvp-item');
|
||||
if (!item) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setValue(picker, item.dataset.value, true); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); (item.nextElementSibling || item).focus(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); (item.previousElementSibling || picker.search).focus(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(picker); picker.trigger.focus(); }
|
||||
});
|
||||
select.addEventListener('change', () => { renderTrigger(picker); close(picker); });
|
||||
document.addEventListener('click', e => { if (!root.contains(e.target)) close(picker); }, true);
|
||||
|
||||
pickers[selectId] = picker;
|
||||
syncFromSelect(selectId);
|
||||
return picker;
|
||||
}
|
||||
|
||||
function populate(selectId, values, options) {
|
||||
const picker = upgrade(selectId, options || {});
|
||||
if (!picker) return;
|
||||
picker.placeholder = options?.placeholder || picker.placeholder;
|
||||
picker.empty = options?.empty || picker.empty;
|
||||
const previous = picker.select.value;
|
||||
picker.items = (values || []).map(norm).filter(item => item.id);
|
||||
picker.select.innerHTML = `<option value="">${esc(picker.placeholder)}</option>` + picker.items.map(item => `<option value="${esc(item.id)}">${esc(optionLabel(item))}</option>`).join('');
|
||||
if (previous && picker.items.some(item => item.id === previous)) picker.select.value = previous;
|
||||
renderTrigger(picker);
|
||||
renderList(picker, picker.search?.value || '');
|
||||
}
|
||||
|
||||
function syncFromSelect(selectId) {
|
||||
const picker = pickers[selectId];
|
||||
if (!picker) return;
|
||||
picker.items = Array.from(picker.select.options).filter(o => o.value).map(o => ({ id: o.value, label: o.textContent || o.value, meta: libraryVoice(o.value) || {} }));
|
||||
renderTrigger(picker);
|
||||
renderList(picker, '');
|
||||
}
|
||||
|
||||
function getValue(selectId) {
|
||||
return document.getElementById(selectId)?.value || '';
|
||||
}
|
||||
|
||||
function set(selectId, value) {
|
||||
const picker = pickers[selectId] || upgrade(selectId);
|
||||
if (!picker) return;
|
||||
setValue(picker, value, true);
|
||||
}
|
||||
|
||||
window.BenchmarkVoicePicker = { upgrade, populate, syncFromSelect, getValue, set };
|
||||
})();
|
||||
@ -434,18 +434,30 @@
|
||||
async function loadBenchmarkVoiceLibrary() {
|
||||
const sel = q('bench-stt-library-voice');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Loading voices...</option>';
|
||||
const picker = window.BenchmarkVoicePicker;
|
||||
if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Loading voices...', empty: 'Loading voices...' });
|
||||
else sel.innerHTML = '<option value="">Loading voices...</option>';
|
||||
try {
|
||||
const voices = await fetchJson('/api/voices');
|
||||
const usable = (voices || []).filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim());
|
||||
sel.innerHTML = '<option value="">-- choose from voice library --</option>' + usable
|
||||
.sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id)))
|
||||
.map(v => `<option value="${esc(v.id)}">${esc(v.display_name || v.name || v.id)}${v.duration ? ` (${Number(v.duration).toFixed(1)}s)` : ''}</option>`)
|
||||
.join('');
|
||||
const usable = (voices || [])
|
||||
.filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim())
|
||||
.sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id)));
|
||||
const items = usable.map(v => ({ id: v.id, label: v.display_name || v.name || v.id, meta: v }));
|
||||
if (picker) {
|
||||
picker.populate('bench-stt-library-voice', items, {
|
||||
placeholder: '-- choose from voice library --',
|
||||
empty: 'No library voices with reference transcripts found',
|
||||
});
|
||||
} else {
|
||||
sel.innerHTML = '<option value="">-- choose from voice library --</option>' + usable
|
||||
.map(v => `<option value="${esc(v.id)}">${esc(v.display_name || v.name || v.id)}${v.duration ? ` (${Number(v.duration).toFixed(1)}s)` : ''}</option>`)
|
||||
.join('');
|
||||
}
|
||||
const note = q('bench-stt-source-note');
|
||||
if (note) note.textContent = usable.length ? `${usable.length} voices with reference text available.` : 'No library voices with reference transcripts found.';
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="">Voice library unavailable</option>';
|
||||
if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Voice library unavailable', empty: 'Voice library unavailable' });
|
||||
else sel.innerHTML = '<option value="">Voice library unavailable</option>';
|
||||
const note = q('bench-stt-source-note');
|
||||
if (note) note.textContent = 'Could not load voice library: ' + e.message;
|
||||
}
|
||||
@ -649,13 +661,19 @@
|
||||
const btn = q('bench-turn-fetch-voices');
|
||||
const sel = q('bench-turn-voice');
|
||||
const backend = q('bench-turn-tts-backend')?.value || 'voice_clone';
|
||||
const picker = window.BenchmarkVoicePicker;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const raw = await fetchJson('/api/tts-voices?backend=' + encodeURIComponent(backend));
|
||||
const ids = (Array.isArray(raw) ? raw : []).map(v => typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name))).filter(Boolean);
|
||||
if (sel) sel.innerHTML = ids.length ? ids.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('') : '<option value="">No voices</option>';
|
||||
const items = (Array.isArray(raw) ? raw : []).map(v => {
|
||||
const id = typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name));
|
||||
return id ? { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) } : null;
|
||||
}).filter(Boolean);
|
||||
if (picker) picker.populate('bench-turn-voice', items, { placeholder: 'Fetch voices', empty: 'No voices' });
|
||||
else if (sel) sel.innerHTML = items.length ? items.map(v => `<option value="${esc(v.id)}">${esc(v.id)}</option>`).join('') : '<option value="">No voices</option>';
|
||||
} catch (e) {
|
||||
if (sel) sel.innerHTML = '<option value="">Fetch failed</option>';
|
||||
if (picker) picker.populate('bench-turn-voice', [], { placeholder: 'Fetch failed', empty: 'Fetch failed' });
|
||||
else if (sel) sel.innerHTML = '<option value="">Fetch failed</option>';
|
||||
say('Voice fetch failed: ' + e.message, 'error');
|
||||
} finally { if (btn) btn.disabled = false; }
|
||||
}
|
||||
@ -781,6 +799,11 @@
|
||||
if (initialized || !q('bench-stt-run')) return;
|
||||
initialized = true;
|
||||
document.querySelectorAll('.bench-tab').forEach(btn => btn.addEventListener('click', () => setBenchTab(btn.dataset.benchTab)));
|
||||
if (window.BenchmarkVoicePicker) {
|
||||
BenchmarkVoicePicker.upgrade('bench-stt-library-voice', { placeholder: '-- choose from voice library --', empty: 'No library voices with reference transcripts found' });
|
||||
BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' });
|
||||
BenchmarkVoicePicker.upgrade('bench-turn-voice', { placeholder: 'Fetch voices', empty: 'No voices' });
|
||||
}
|
||||
document.addEventListener('click', () => closeBenchmarkModelPickers());
|
||||
q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines);
|
||||
q('bench-stt-load-voice')?.addEventListener('click', useBenchmarkLibraryVoice);
|
||||
@ -790,7 +813,10 @@
|
||||
q('bench-turn-fetch-llm')?.addEventListener('click', fetchTurnModels);
|
||||
q('bench-turn-fetch-voices')?.addEventListener('click', fetchTurnVoices);
|
||||
q('bench-turn-run')?.addEventListener('click', runTurnBenchmark);
|
||||
q('bench-turn-tts-backend')?.addEventListener('change', () => { if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = '<option value="">Fetch voices</option>'; });
|
||||
q('bench-turn-tts-backend')?.addEventListener('change', () => {
|
||||
if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.populate('bench-turn-voice', [], { placeholder: 'Fetch voices', empty: 'No voices' });
|
||||
else if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = '<option value="">Fetch voices</option>';
|
||||
});
|
||||
loadBenchmarkSttEngines();
|
||||
loadBenchmarkVoiceLibrary();
|
||||
initTurnControls();
|
||||
|
||||
@ -390,6 +390,7 @@ function renderPerfHistory() {
|
||||
).join('') || '<option value="">No backends available</option>';
|
||||
}
|
||||
populatePerfBackends();
|
||||
if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' });
|
||||
|
||||
perfFetchBtn.addEventListener('click', async () => {
|
||||
const backend = perfBackendSel.value;
|
||||
@ -398,10 +399,16 @@ function renderPerfHistory() {
|
||||
try {
|
||||
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||||
const cur = perfVoiceSel.value;
|
||||
perfVoiceSel.innerHTML = rawVoices.map(v => {
|
||||
const items = rawVoices.map(v => {
|
||||
const id = backendVoiceId(v);
|
||||
return `<option value="${escHtml(id)}"${id===cur?' selected':''}>${escHtml(id)}</option>`;
|
||||
}).join('') || '<option value="">No voices</option>';
|
||||
return { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) };
|
||||
}).filter(v => v.id);
|
||||
if (window.BenchmarkVoicePicker) {
|
||||
BenchmarkVoicePicker.populate('perf-voice-select', items, { placeholder: '-- select after fetch --', empty: 'No voices' });
|
||||
if (cur && items.some(v => v.id === cur)) BenchmarkVoicePicker.set('perf-voice-select', cur);
|
||||
} else {
|
||||
perfVoiceSel.innerHTML = items.map(v => `<option value="${escHtml(v.id)}"${v.id===cur?' selected':''}>${escHtml(v.id)}</option>`).join('') || '<option value="">No voices</option>';
|
||||
}
|
||||
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
|
||||
finally { perfFetchBtn.disabled = false; }
|
||||
});
|
||||
|
||||
@ -108,6 +108,7 @@
|
||||
// default loads them individually so editing static/js/* is live without a build.
|
||||
var _featureFiles = [
|
||||
'/static/js/voice-picker.js',
|
||||
'/static/js/benchmark-voice-picker.js',
|
||||
'/static/js/voice-inspector.js',
|
||||
'/static/js/voice-sources.js',
|
||||
'/static/js/fishaudio-browser.js',
|
||||
|
||||
@ -2389,7 +2389,44 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.bench-source-row { display: flex; gap: 8px; align-items: center; }
|
||||
.bench-source-row select { flex: 1; min-width: 220px; }
|
||||
.bench-source-note { margin-top: 5px; color: var(--subtext); font-size: 12px; }
|
||||
@media (max-width: 700px) { .bench-source-row { flex-direction: column; align-items: stretch; } }
|
||||
.bench-source-row .bvp-root { flex: 1 1 360px; min-width: min(360px, 100%); }
|
||||
.bvp-root { position: relative; display: inline-flex; flex-direction: column; min-width: 260px; flex: 1 1 300px; }
|
||||
.bvp-trigger {
|
||||
width: 100%; min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 0 10px; cursor: pointer; background: var(--input-bg, var(--panel)); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius, 8px); text-align: left; user-select: none;
|
||||
}
|
||||
.bvp-trigger:hover, .bvp-trigger.open { border-color: var(--accent); filter: none; }
|
||||
.bvp-trigger-content { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
||||
.bvp-trigger-main { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.bvp-trigger-name { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--text); }
|
||||
.bvp-trigger-stats { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; }
|
||||
.bvp-placeholder { font-size: 13px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bvp-chevron { color: var(--subtext); flex-shrink: 0; transition: transform .15s; }
|
||||
.bvp-trigger.open .bvp-chevron { transform: rotate(180deg); }
|
||||
.bvp-avatar { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.bvp-drop[hidden] { display: none !important; }
|
||||
.bvp-drop {
|
||||
position: absolute; z-index: 245; top: calc(100% + 4px); left: 0; right: 0; min-width: min(520px, 92vw);
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius, 8px);
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.18); overflow: hidden;
|
||||
}
|
||||
.bvp-search-wrap { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--subtext); background: var(--panel); }
|
||||
.bvp-search { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--text); font-size: 13px; }
|
||||
.bvp-list { max-height: 340px; overflow-y: auto; padding: 4px; }
|
||||
.bvp-item {
|
||||
width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 8px;
|
||||
border: 0; border-radius: 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer;
|
||||
}
|
||||
.bvp-item:hover, .bvp-item:focus { background: var(--hover); outline: none; filter: none; }
|
||||
.bvp-item.selected { background: rgba(37,99,235,.12); color: var(--accent); }
|
||||
.bvp-item-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||
.bvp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; }
|
||||
.bvp-item-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); }
|
||||
.bvp-item-stats { flex-shrink: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; }
|
||||
.bvp-empty { padding: 14px 12px; text-align: center; color: var(--subtext); font-size: 12px; }
|
||||
.bench-inline-controls .bvp-root { min-width: min(340px, 100%); flex: 1 1 340px; }
|
||||
@media (max-width: 700px) { .bench-source-row { flex-direction: column; align-items: stretch; } .bvp-drop { min-width: 100%; } .bvp-item-stats { max-width: 110px; } }
|
||||
.bench-engine-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); overflow: hidden; }
|
||||
.bench-engine-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.bench-engine-head span { color: var(--subtext); }
|
||||
@ -2446,7 +2483,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.bench-engine-item { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.bench-engine-state, .bench-engine-model { grid-column: 2; }
|
||||
.bench-inline-controls { flex-wrap: wrap; min-width: 0; }
|
||||
.bench-inline-controls input, .bench-inline-controls select { flex: 1 1 180px; }
|
||||
.bench-inline-controls input, .bench-inline-controls select, .bench-inline-controls .bvp-root { flex: 1 1 180px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.bench-tab span:not(.mdi) { display: none; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user