feat(engines): searchable dropdown for the Active LLM and OCR model pickers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-20 16:19:26 +02:00
parent 4b59d06479
commit 5852d7a2d5
6 changed files with 36 additions and 17 deletions

View File

@ -7,6 +7,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed
- **Searchable model dropdown** for the Active Language Model and Active OCR Model pickers (Engines page): the long native model list is now the same type-to-search dropdown used for voices, with model-specific wording and no avatars.
### Changed ### Changed
- **Character images: period-safe, uninjured, less uniform.** Costume and appearance text sent to the image model is now rewritten to period garments (Uniform, Weste, Hut, Sporen, Jacke, Schal … became livery tabard, jerkin, felt cap, doublet …) because the extracted sheets contained such words for ~45 characters and the images came out in wartime/cowboy dress; injury clauses (blood, wounds, bandages, burns, limping, torn clothes) are removed clause by clause from appearance, costume and the story-moment text instead of dropping whole sentences; characters with armour or weapons get an individual garment cut and colour scheme (stable per name, colours named in the sheet win) instead of one shared list of gambeson/brigandine/plate. Saved prompts are cleaned the same way at render time. Adult figures are described as eight head-heights tall with a small head, to counter the short, stubby turnaround figures. The stored sheets are unchanged. - **Character images: period-safe, uninjured, less uniform.** Costume and appearance text sent to the image model is now rewritten to period garments (Uniform, Weste, Hut, Sporen, Jacke, Schal … became livery tabard, jerkin, felt cap, doublet …) because the extracted sheets contained such words for ~45 characters and the images came out in wartime/cowboy dress; injury clauses (blood, wounds, bandages, burns, limping, torn clothes) are removed clause by clause from appearance, costume and the story-moment text instead of dropping whole sentences; characters with armour or weapons get an individual garment cut and colour scheme (stable per name, colours named in the sheet win) instead of one shared list of gambeson/brigandine/plate. Saved prompts are cleaned the same way at render time. Adult figures are described as eight head-heights tall with a small head, to counter the short, stubby turnaround figures. The stored sheets are unchanged.
- **The turnaround now shows the same person as the profile picture.** Front, side and rear views are drawn one by one by the edit model from the identity portrait (the old text-only triptych invented a new face). Book setting reworded to Dark Eye high-fantasy medieval (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), shields/cloaks no longer added to everyone, soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated. - **The turnaround now shows the same person as the profile picture.** Front, side and rear views are drawn one by one by the edit model from the identity portrait (the old text-only triptych invented a new face). Book setting reworded to Dark Eye high-fantasy medieval (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), shields/cloaks no longer added to everyone, soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated.

View File

@ -1 +1 @@
1.34.0 1.34.1

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.34.0"> <meta name="app-version" content="1.34.1">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.34.0"> <link rel="stylesheet" href="/static/style.css?v=1.34.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.34.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.34.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.34.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.34.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.34.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.34.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.34.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.34.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.34.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.34.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -299,8 +299,19 @@
}); });
}); });
// Model lists are long, so the model <select>s become the searchable dropdown used elsewhere.
// The <select> stays the source of truth; the picker re-reads it whenever its options change.
function upgradeModelSelect(id) {
const sel = document.getElementById(id);
const picker = window.BenchmarkVoicePicker;
if (!sel || !picker) return;
picker.upgrade(id, { noun: 'models', plain: true, empty: 'No models', placeholder: '— fetch models —' });
new MutationObserver(() => picker.syncFromSelect(id)).observe(sel, { childList: true });
}
// ── Active Language Model (global picker) ─────────────────────────────── // ── Active Language Model (global picker) ───────────────────────────────
(function initActiveLlmPanel() { (function initActiveLlmPanel() {
upgradeModelSelect('llm-active-model');
const urlInp = $('llm-active-url'); const urlInp = $('llm-active-url');
const modelSel = $('llm-active-model'); const modelSel = $('llm-active-model');
const refreshBtn = $('llm-active-refresh'); const refreshBtn = $('llm-active-refresh');
@ -443,6 +454,7 @@
// proxies to whatever URL it's given rather than assuming it's the LLM one. // proxies to whatever URL it's given rather than assuming it's the LLM one.
(function initActiveOcrPanel() { (function initActiveOcrPanel() {
const urlInp = $('ocr-active-url'); const urlInp = $('ocr-active-url');
upgradeModelSelect('ocr-active-model');
const modelSel = $('ocr-active-model'); const modelSel = $('ocr-active-model');
const refreshBtn = $('ocr-active-refresh'); const refreshBtn = $('ocr-active-refresh');
const statusEl = $('ocr-active-status'); const statusEl = $('ocr-active-status');

View File

@ -113,14 +113,14 @@
return; return;
} }
const stats = statsFor(item); 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>`; content.innerHTML = `${picker.plain ? '' : 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) { function renderList(picker, filter) {
const q = String(filter || '').trim().toLowerCase(); const q = String(filter || '').trim().toLowerCase();
const items = q ? picker.items.filter(item => searchText(item).includes(q)) : picker.items; const items = q ? picker.items.filter(item => searchText(item).includes(q)) : picker.items;
if (!items.length) { if (!items.length) {
picker.list.innerHTML = `<div class="bvp-empty">${esc(q ? 'No voices found' : picker.empty)}</div>`; picker.list.innerHTML = `<div class="bvp-empty">${esc(q ? `No ${picker.noun} found` : picker.empty)}</div>`;
return; return;
} }
picker.list.innerHTML = items.map(item => { picker.list.innerHTML = items.map(item => {
@ -128,7 +128,7 @@
const stats = statsFor(item); const stats = statsFor(item);
const sub = [meta.lang || meta.language, meta.file_type].filter(Boolean).join(' · '); 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)}"> return `<button class="bvp-item${item.id === picker.select.value ? ' selected' : ''}" type="button" data-value="${esc(item.id)}">
${avatarHtml(item, 28)} ${picker.plain ? '' : 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> <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>` : ''} ${stats ? `<span class="bvp-item-stats">${esc(stats)}</span>` : ''}
</button>`; </button>`;
@ -175,6 +175,8 @@
items: [], items: [],
placeholder: options?.placeholder || Array.from(select.options).find(o => !o.value)?.textContent || '— select after fetch —', placeholder: options?.placeholder || Array.from(select.options).find(o => !o.value)?.textContent || '— select after fetch —',
empty: options?.empty || 'No voices', empty: options?.empty || 'No voices',
noun: options?.noun || 'voices',
plain: !!options?.plain,
}; };
select.dataset.bvpUpgraded = '1'; select.dataset.bvpUpgraded = '1';
select.style.display = 'none'; select.style.display = 'none';
@ -189,7 +191,7 @@
<span class="mdi mdi-chevron-down bvp-chevron"></span> <span class="mdi mdi-chevron-down bvp-chevron"></span>
</button> </button>
<div class="bvp-drop" hidden> <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-search-wrap"><span class="mdi mdi-magnify bvp-search-icon"></span><input class="bvp-search" type="search" placeholder="Search ${esc(picker.noun)}..." autocomplete="off" spellcheck="false"></div>
<div class="bvp-list"></div> <div class="bvp-list"></div>
</div>`; </div>`;
@ -250,6 +252,8 @@
function syncFromSelect(selectId) { function syncFromSelect(selectId) {
const picker = pickers[selectId]; const picker = pickers[selectId];
if (!picker) return; if (!picker) return;
const blank = Array.from(picker.select.options).find(o => !o.value);
if (blank && blank.textContent) picker.placeholder = blank.textContent;
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) || {} })); 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); renderTrigger(picker);
renderList(picker, ''); renderList(picker, '');