Refactor table and audiobook helper modules

This commit is contained in:
mARTin-B78 2026-08-19 16:39:17 +02:00
parent 388fd601ab
commit b953ba1a83
7 changed files with 143 additions and 141 deletions

View File

@ -22,8 +22,8 @@ mkdirSync(outDir, { recursive: true });
const MAIN = [
'voice-picker', 'benchmark-voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
'characters-library', 'sillytavern', 'library', 'library-characters', 'studio',
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'audiobook/network', 'character-sheets',
'characters-library', 'sillytavern', 'library', 'character-columns', 'library-characters', 'studio',
].map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

File diff suppressed because one or more lines are too long

View File

@ -60,98 +60,6 @@ window._audiobook = _audiobook;
let _abDraftAutosaveTimer = null;
let _abLastServerDraftErrorAt = 0;
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const parentSignal = options.signal;
const ac = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ac.abort();
}, timeoutMs);
const onParentAbort = () => ac.abort(parentSignal?.reason);
if (parentSignal) {
if (parentSignal.aborted) onParentAbort();
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: ac.signal });
} catch (err) {
if (timedOut) {
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
timeoutErr.name = 'TimeoutError';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
}
}
function audiobookTimeoutSeconds(timeoutMs) {
return Math.max(5, Math.round(timeoutMs / 1000));
}
// Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream,
// pushing each delta into view.thinking() so the user can watch the LLM work,
// and resolves with the final parsed result. Uses an INACTIVITY timeout (reset
// on every received chunk) rather than an overall one — a passage legitimately
// takes minutes on a slow model, but silence that long means the stream died.
// Throws on any failure; callers fall back to the blocking endpoint. A user
// Stop (outer signal) propagates as AbortError; a stalled stream does not.
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const ctl = new AbortController();
const onAbort = () => ctl.abort();
if (outerSignal) {
if (outerSignal.aborted) ctl.abort();
else outerSignal.addEventListener('abort', onAbort, { once: true });
}
let idleTimer = null;
const armIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
};
try {
armIdle();
const r = await fetch('/api/attribute-dialogue/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }),
});
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', result = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
armIdle();
buf += dec.decode(value, { stream: true });
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, at).trim();
buf = buf.slice(at + 2);
if (!line.startsWith('data:')) continue;
let d;
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
if (d.t && view?.thinking) view.thinking(d.t);
if (d.error) throw new Error(d.error);
if (d.done) result = d.result || null;
}
}
if (!result) throw new Error('stream ended without result');
return result;
} catch (err) {
// An idle-timeout abort is a stream failure (fall back), not a user Stop.
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
throw new Error('stream idle timeout');
}
throw err;
} finally {
clearTimeout(idleTimer);
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
}
}
function audiobookDialogueKey(text) {
return String(text || '')
.normalize('NFKC')

View File

@ -0,0 +1,87 @@
// Network and streaming helpers for audiobook attribution.
// Loaded after audiobook.js; the functions are called from user actions and
// therefore do not need to execute while the main audiobook module initializes.
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const parentSignal = options.signal;
const ac = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ac.abort();
}, timeoutMs);
const onParentAbort = () => ac.abort(parentSignal?.reason);
if (parentSignal) {
if (parentSignal.aborted) onParentAbort();
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: ac.signal });
} catch (err) {
if (timedOut) {
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
timeoutErr.name = 'TimeoutError';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
}
}
function audiobookTimeoutSeconds(timeoutMs) {
return Math.max(5, Math.round(timeoutMs / 1000));
}
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const ctl = new AbortController();
const onAbort = () => ctl.abort();
if (outerSignal) {
if (outerSignal.aborted) ctl.abort();
else outerSignal.addEventListener('abort', onAbort, { once: true });
}
let idleTimer = null;
const armIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
};
try {
armIdle();
const r = await fetch('/api/attribute-dialogue/stream', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }),
});
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '', result = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
armIdle();
buf += dec.decode(value, { stream: true });
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, at).trim();
buf = buf.slice(at + 2);
if (!line.startsWith('data:')) continue;
let d;
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
if (d.t && view?.thinking) view.thinking(d.t);
if (d.error) throw new Error(d.error);
if (d.done) result = d.result || null;
}
}
if (!result) throw new Error('stream ended without result');
return result;
} catch (err) {
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
throw new Error('stream idle timeout');
}
throw err;
} finally {
clearTimeout(idleTimer);
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
}
}

View File

@ -0,0 +1,47 @@
// Character-table metadata and the persisted column-visibility controller.
// Kept separate from the character renderer so table layout can evolve
// without making library-characters.js responsible for every table concern.
const _CHAR_PROFILE_COMPLETENESS_FIELDS = [
'gender', 'age_estimate', 'race_species', 'languages', 'nationality_background',
'social_class', 'profession', 'title', 'archetype', 'physical', 'clothing',
'alignment', 'arc_direction', 'arc_note', 'attribute_high', 'attribute_low',
'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears',
'mannerisms', 'communication_style', 'voice_pattern', 'secret', 'conflict_style',
'win_condition', 'reputation', 'religious_beliefs', 'notes', 'inventory',
];
const _CHAR_TABLE_COLUMN_DEFS = [
['select', 'Select'], ['avatar', 'Avatar'], ['name', 'Name'], ['gender', 'Gender'],
['age', 'Age'], ['lines', 'Lines'], ['language', 'Language'], ['alignment', 'Good / Evil'],
['profile', 'Profile'], ['voice', 'Voice'], ['rating', 'Rating'], ['prompt', 'Voice Design Prompt'],
['notes', 'Notes'], ['tags', 'Tags'], ['book', 'Book / Script'], ['export', 'Export'],
];
function _charTableHiddenColumns() {
try {
const saved = JSON.parse(localStorage.getItem('ttsvc_libchars_columns') || '{}');
return saved && typeof saved === 'object' ? saved : {};
} catch (_) { return {}; }
}
function _applyCharTableColumns(table, hidden) {
if (!table) return;
_CHAR_TABLE_COLUMN_DEFS.forEach(function (def, index) {
const isHidden = hidden[def[0]] === false;
table.querySelectorAll('tr').forEach(function (row) {
const cell = row.children[index];
if (cell) cell.classList.toggle('lib-chars-col-hidden', isHidden);
});
const col = table.querySelector('colgroup')?.children[index];
if (col) col.classList.toggle('lib-chars-col-hidden', isHidden);
});
}
function _charProfileCompleteness(sheet) {
const sh = sheet || {};
const filled = _CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function (key) {
return _libStr(sh[key]).trim().length > 0;
}).length;
return { filled: filled, total: _CHAR_PROFILE_COMPLETENESS_FIELDS.length };
}

View File

@ -899,48 +899,6 @@ function _charCardHtml(rec, allChars) {
+ '</div></div>';
}
// These are the 32 substantive profile fields shown in the character sheet.
// Identity/name and generated assets are intentionally excluded.
const _CHAR_PROFILE_COMPLETENESS_FIELDS = [
'gender', 'age_estimate', 'race_species', 'languages', 'nationality_background',
'social_class', 'profession', 'title', 'archetype', 'physical', 'clothing',
'alignment', 'arc_direction', 'arc_note', 'attribute_high', 'attribute_low',
'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears',
'mannerisms', 'communication_style', 'voice_pattern', 'secret', 'conflict_style',
'win_condition', 'reputation', 'religious_beliefs', 'notes', 'inventory',
];
const _CHAR_TABLE_COLUMN_DEFS = [
['select', 'Select'], ['avatar', 'Avatar'], ['name', 'Name'], ['gender', 'Gender'],
['age', 'Age'], ['lines', 'Lines'], ['language', 'Language'], ['alignment', 'Good / Evil'],
['profile', 'Profile'], ['voice', 'Voice'], ['rating', 'Rating'], ['prompt', 'Voice Design Prompt'],
['notes', 'Notes'], ['tags', 'Tags'], ['book', 'Book / Script'], ['export', 'Export'],
];
function _charTableHiddenColumns() {
try {
const saved = JSON.parse(localStorage.getItem('ttsvc_libchars_columns') || '{}');
return saved && typeof saved === 'object' ? saved : {};
} catch (_) { return {}; }
}
function _applyCharTableColumns(table, hidden) {
if (!table) return;
_CHAR_TABLE_COLUMN_DEFS.forEach(function (def, index) {
const isHidden = hidden[def[0]] === false;
table.querySelectorAll('tr').forEach(function (row) {
const cell = row.children[index];
if (cell) cell.classList.toggle('lib-chars-col-hidden', isHidden);
});
const col = table.querySelector('colgroup')?.children[index];
if (col) col.classList.toggle('lib-chars-col-hidden', isHidden);
});
}
function _charProfileCompleteness(sheet) {
const sh = sheet || {};
const filled = _CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function (key) {
return _libStr(sh[key]).trim().length > 0;
}).length;
return { filled: filled, total: _CHAR_PROFILE_COMPLETENESS_FIELDS.length };
}
// Compact alternative to the card grid for scanning a large cast at once —
// one row per character with the fields that matter for casting decisions.
// Rows carry the "lib-char-card" class too so they're picked up by the same

View File

@ -145,10 +145,12 @@
'/static/js/rehearser.js',
'/static/js/reader.js',
'/static/js/audiobook.js',
'/static/js/audiobook/network.js',
'/static/js/character-sheets.js',
'/static/js/characters-library.js',
'/static/js/sillytavern.js',
'/static/js/library.js',
'/static/js/character-columns.js',
'/static/js/library-characters.js',
'/static/js/studio.js',
];