// ── 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;