tts-voice-creator-clone-and.../static/js/sillytavern.js
2026-06-30 13:53:08 +02:00

191 lines
8.3 KiB
JavaScript

// ── SillyTavern character-card compatibility ──────────────────────────────────
//
// Import and export characters in SillyTavern / TavernAI "Character Card" format
// so a cast built here can be moved to/from SillyTavern (and the wider ecosystem
// that follows the same spec: AI Character Editor, Chub, Agnai, …).
//
// Supported on import:
// • V1 (legacy TavernAI): flat JSON { name, description, personality, … }
// • V2: { spec:'chara_card_v2', spec_version:'2.0', data:{ … } }
// • V3: { spec:'chara_card_v3', data:{ … } } (read; extra V3 fields preserved)
// • PNG cards: JSON is base64 in a tEXt chunk (keyword 'chara' = V2, 'ccv3' = V3)
//
// Export is V2 JSON — the most widely accepted format. The assigned TTS voice is
// stored under data.extensions.tts_voice so a round-trip keeps the casting.
// ── PNG tEXt chunk reader ─────────────────────────────────────────────────────
// PNG = 8-byte signature then chunks of [len:4][type:4][data:len][crc:4].
// SillyTavern stores the card as base64 JSON in a tEXt chunk whose keyword is
// 'chara' (V2) or 'ccv3' (V3): data = keyword + 0x00 + base64text.
function stReadPngText(buf) {
const dv = new DataView(buf);
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < 8; i++) if (dv.getUint8(i) !== sig[i]) throw new Error('Not a PNG file');
let off = 8;
const out = {};
while (off + 8 <= dv.byteLength) {
const len = dv.getUint32(off); off += 4;
let type = '';
for (let i = 0; i < 4; i++) type += String.fromCharCode(dv.getUint8(off + i));
off += 4;
if (type === 'tEXt') {
const bytes = new Uint8Array(buf, off, len);
let sep = bytes.indexOf(0);
if (sep === -1) sep = len;
let keyword = '';
for (let i = 0; i < sep; i++) keyword += String.fromCharCode(bytes[i]);
const text = new TextDecoder('latin1').decode(bytes.subarray(sep + 1));
out[keyword] = text;
}
off += len + 4; // skip data + CRC
if (type === 'IEND') break;
}
return out;
}
function stB64ToJson(b64) {
const bin = atob(b64.trim());
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return JSON.parse(new TextDecoder('utf-8').decode(bytes));
}
// Normalise any card variant into the V2-style "data" object.
function stNormalize(card) {
if (!card || typeof card !== 'object') throw new Error('Invalid card JSON');
if (card.data && typeof card.data === 'object' && card.data.name != null) return card.data; // V2/V3
if (card.name != null) return card; // V1 flat
throw new Error('Unrecognised character-card structure');
}
// Parse a File (.json or .png) into the normalised data object.
async function stParseFile(file) {
const name = (file.name || '').toLowerCase();
if (name.endsWith('.png')) {
const buf = await file.arrayBuffer();
const chunks = stReadPngText(buf);
const raw = chunks['ccv3'] || chunks['chara'];
if (!raw) throw new Error('PNG has no embedded character card');
return stNormalize(stB64ToJson(raw));
}
// JSON (or anything else we try as text)
const txt = await file.text();
return stNormalize(JSON.parse(txt));
}
// ── Mapping: SillyTavern data → our character sheet ───────────────────────────
function stGuessGender(text) {
const t = String(text || '').toLowerCase();
const f = (t.match(/\b(she|her|female|woman|girl|mother|daughter|sister|queen|lady|mrs|miss)\b/g) || []).length;
const m = (t.match(/\b(he|him|his|male|man|boy|father|son|brother|king|lord|mr|sir)\b/g) || []).length;
if (f > m * 1.3) return 'female';
if (m > f * 1.3) return 'male';
return '';
}
function stToSheet(data) {
const tagList = Array.isArray(data.tags) ? data.tags : String(data.tags || '').split(',');
const tags = tagList.map(t => String(t || '').trim()).filter(Boolean).join(', ');
const ext = data.extensions || {};
return {
name: (data.name || data.char_name || 'Unnamed').trim(),
archetype: (data.creator_notes || '').split('\n')[0].slice(0, 60) || '',
backstory: data.description || data.char_persona || '',
mannerisms: data.personality || '',
arc_note: data.scenario || '',
voice_pattern: data.mes_example || data.example_dialogue || data.first_mes || data.char_greeting || '',
gender: ext.gender || stGuessGender((data.description || '') + ' ' + (data.personality || '')),
note: data.creator_notes || '',
tags,
voice: ext.tts_voice || null,
tier: 'supporting',
st_card: data, // full original, kept for lossless round-trip export
};
}
// ── Mapping: our record → SillyTavern V2 card ─────────────────────────────────
function stFromRecord(rec) {
const sh = rec.sheet || {};
const prev = sh.st_card || {};
const descParts = [sh.physical, sh.clothing, sh.backstory, sh.alignment]
.map(x => String(x || '').trim()).filter(Boolean);
const persona = [sh.archetype, sh.mannerisms, sh.motivation, sh.fears]
.map(x => String(x || '').trim()).filter(Boolean).join('\n');
return {
spec: 'chara_card_v2',
spec_version: '2.0',
data: {
name: rec.name,
description: prev.description || descParts.join('\n\n'),
personality: persona || prev.personality || '',
scenario: rec.book || prev.scenario || '',
first_mes: prev.first_mes || '',
mes_example: sh.voice_pattern || prev.mes_example || '',
creator_notes: 'Exported from TTS Voice Creator' + (rec.book ? ' · ' + rec.book : ''),
system_prompt: prev.system_prompt || '',
post_history_instructions: prev.post_history_instructions || '',
tags: String(rec.tags || rec.book || '').split(',').map(t => t.trim()).filter(Boolean),
creator: prev.creator || '',
character_version: prev.character_version || '1.0',
extensions: Object.assign({}, prev.extensions, { tts_voice: rec.voice || '' }),
},
};
}
function stDownloadJson(card, filename) {
const blob = new Blob([JSON.stringify(card, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// ── Public actions ────────────────────────────────────────────────────────────
// Import one or more ST cards into a production (book). Returns count imported.
async function stImportCards(book, fileList) {
let n = 0;
for (const file of fileList) {
try {
const data = await stParseFile(file);
const sheet = stToSheet(data);
if (typeof clUpsert === 'function') {
await clUpsert(book || sheet.name, Object.assign({}, sheet, { tags: book || '' }));
n++;
}
} catch (e) {
if (typeof toast === 'function') toast('“' + (file.name || 'card') + '”: ' + (e.message || e), 'error');
}
}
return n;
}
// Trigger a file picker and import into `book`, then refresh the cast view.
function stImportDialog(book, onDone) {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.json,.png';
inp.multiple = true;
inp.onchange = async () => {
if (!inp.files.length) return;
const n = await stImportCards(book, inp.files);
if (typeof toast === 'function') toast(n ? 'Imported ' + n + ' character' + (n > 1 ? 's' : '') : 'Nothing imported', n ? 'success' : 'error');
if (typeof onDone === 'function') onDone();
};
inp.click();
}
// Export a single character record as an ST V2 JSON card.
function stExportRecord(rec) {
const card = stFromRecord(rec);
const safe = String(rec.name || 'character').replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '_') || 'character';
stDownloadJson(card, safe + '.card.json');
}
window.stParseFile = stParseFile;
window.stToSheet = stToSheet;
window.stFromRecord = stFromRecord;
window.stImportCards = stImportCards;
window.stImportDialog = stImportDialog;
window.stExportRecord = stExportRecord;