191 lines
8.3 KiB
JavaScript
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;
|