feat: add visual pair review workflow

This commit is contained in:
mARTin-B78 2026-09-04 09:20:27 +02:00
parent 8ed1f122a3
commit a7a6bb1cdc
6 changed files with 47 additions and 2 deletions

View File

@ -35,6 +35,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
job status/cancellation endpoints while reusing the existing casting validation, retries, and idempotency behavior.
- **Attribution Fixture Coverage.** Added a neutral multi-speaker Book 2-style fixture regression to verify parser
normalization across named speakers and narration without requiring a live provider.
- **Visual Pair Review Workflow.** The character library now exposes the read-only visual-pair audit directly in its
toolbar and displays actionable per-character findings without changing saved assets.
- **STT Cancellation Safety.** Queued transcription cancels before consuming the single STT slot, and timed-out
requests keep the device slot held until the underlying worker actually exits.
- **Credential Boundary Review.** Provider credentials are now documented as server-only; only the explicitly

View File

@ -1 +1 @@
1.22.7
1.22.8

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,40 @@
let _libCharsRenderSeq = 0;
async function _libReviewVisualPairs(button, container) {
if (!button) return;
button.disabled = true;
const oldLabel = button.innerHTML;
button.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Reviewing…';
try {
const response = await fetch('/api/characters/visual-pair-review');
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || 'Visual-pair review failed');
let panel = container.querySelector('#lib-chars-visual-review');
if (!panel) {
panel = document.createElement('div');
panel.id = 'lib-chars-visual-review';
panel.className = 'lib-chars-visual-review';
container.insertBefore(panel, container.firstChild);
}
if (!payload.issues || !payload.issues.length) {
panel.innerHTML = '<span class="mdi mdi-check-circle-outline"></span> Visual pairs passed the metadata and asset-reuse checks (' + payload.checked + ' characters reviewed).';
if (typeof toast === 'function') toast('Visual pair review passed', 'success');
} else {
panel.innerHTML = '<strong><span class="mdi mdi-alert-outline"></span> Review ' + payload.issues.length + ' character pair' + (payload.issues.length === 1 ? '' : 's') + ':</strong> '
+ payload.issues.map(function (item) {
return '<span class="lib-chars-review-item"><b>' + escHtml(item.name || item.id) + '</b> — ' + escHtml(item.issues.join(', ')) + '</span>';
}).join('');
if (typeof toast === 'function') toast(payload.issues.length + ' visual pair(s) need review', 'warning');
}
} catch (error) {
if (typeof toast === 'function') toast(error.message || 'Visual-pair review failed', 'error');
} finally {
button.disabled = false;
button.innerHTML = oldLabel;
}
}
async function libraryRenderCharacters() {
const container = document.getElementById('lib-chars-list');
if (!container) return;
@ -120,6 +154,7 @@ async function libraryRenderCharacters() {
? '<button class="btn-secondary btn-sm" id="lib-chars-back-reader" title="Return to Read Aloud"><span class="mdi mdi-arrow-left"></span> Back to reader</button>'
: '')
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
+ '<button class="btn-secondary btn-sm" id="lib-chars-review-visual" title="Check profile and reference assets for stale metadata, duplicates, or reused images"><span class="mdi mdi-image-search-outline"></span> Review visual pairs</button>'
+ '<label class="lib-chars-sort"><span class="mdi mdi-sort"></span> Sort'
+ '<select id="lib-chars-sort-sel">' + SORT_OPTIONS.map(function (o) {
return '<option value="' + o[0] + '"' + (o[0] === sortMode ? ' selected' : '') + '>' + o[1] + '</option>';
@ -132,6 +167,9 @@ async function libraryRenderCharacters() {
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
});
bar.querySelector('#lib-chars-review-visual').addEventListener('click', function () {
_libReviewVisualPairs(this, container);
});
bar.querySelector('#lib-chars-back-reader')?.addEventListener('click', function () {
try { sessionStorage.removeItem('ttsvc_cast_return'); } catch (_) {}
if (typeof navTo === 'function') navTo('s-reader');

View File

@ -599,6 +599,8 @@ audio { width: 100%; }
/* ── Characters / Cast view ─────────────────────────────────────────────────── */
.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
.lib-chars-toolbar #lib-chars-back-reader { margin-right:4px; }
.lib-chars-visual-review { margin:0 0 12px; padding:10px 12px; border:1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius:8px; color:var(--subtext); line-height:1.5; }
.lib-chars-review-item { display:inline-block; margin-left:10px; }
.lib-chars-sort { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--subtext); font-weight:600; }
.lib-chars-sort select {
padding:5px 8px; border-radius:6px; border:1px solid var(--border); background:var(--surface);

View File

@ -15,6 +15,7 @@ const CHARACTERS = [
test('Characters / Cast supports profile sorting, column visibility, and voice rating', async ({ page }) => {
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
await page.route('**/api/characters/visual-pair-review', route => route.fulfill({ json: { ok: true, checked: 2, issues: [] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: VOICES }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
@ -32,6 +33,8 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
const tableToggle = page.locator('.lib-chars-view-toggle button[data-view="table"]');
await expect(tableToggle).toBeVisible({ timeout: 15_000 });
await page.locator('#lib-chars-review-visual').click();
await expect(page.locator('#lib-chars-visual-review')).toContainText('passed');
// Switch through the same persisted view state used by the UI, then wait
// for the asynchronous character rebuild to finish before asserting rows.
await page.evaluate(() => { localStorage.setItem('ttsvc_libchars_view', 'table'); return window.libraryRenderCharacters(); });