Spaces:
Running
Running
File size: 4,790 Bytes
29474fc 0ecdf98 29474fc 0ecdf98 e648dca 0ecdf98 9f5e779 e648dca 6a861b9 0ecdf98 6a861b9 308478f 6a861b9 308478f 6a861b9 308478f 6a861b9 308478f 6a861b9 308478f 6a861b9 308478f 6a861b9 308478f 6a861b9 0ecdf98 6a861b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | // Local-first roster of saved heroes (personas-as-agents). Modeled on woid's Shelter
// store: a single JSON blob in localStorage, simple CRUD, change listeners, and a
// pluggable `sync` hook so a backend can push/pull later WITHOUT changing callers.
// Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio
// blob and cross-device sync are the backend's job (see the plan). Future persona data
// (stats, avatar, xp, relationships) just adds fields to the record.
const KEY = 'tinyarmy.roster.v1'
const listeners = new Set()
let _sync = null // optional { push(records), pull() } — wired to a backend later
function read() {
try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } }
catch { return { personas: [] } }
}
function write(d) {
try { localStorage.setItem(KEY, JSON.stringify(d)) } catch { /* quota / disabled */ }
for (const fn of listeners) { try { fn(d.personas) } catch { /* ignore */ } }
if (_sync && _sync.push) { try { _sync.push(d.personas) } catch { /* best-effort */ } }
}
const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
export function listPersonas() { return read().personas }
export function getPersona(id) { return read().personas.find((p) => p.id === id) || null }
export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }
export function setSync(sync) { _sync = sync }
// Insert or update. Returns the stored record (with id + timestamps).
export function savePersona(p) {
const d = read()
const now = Date.now()
const id = p.id || newId()
const rec = {
id,
name: p.name || 'Unnamed hero',
unitClass: p.unitClass || '',
about: p.about || '',
quote: p.quote || '',
voice: p.voice || '',
// Named voice for fixed-voice providers (Kokoro/Kitten/Web Speech); the design `voice`
// text above is what Qwen3-TTS uses instead. Both are saved so a hero keeps its voice.
voiceId: p.voiceId || '',
specialty: p.specialty || '',
personality: p.personality || '',
vibe: p.vibe || '',
seed: p.seed || '',
// What the cached voice file (IndexedDB) was made for — so after a refresh we know
// the audio is current (replay it) vs stale (re-make it), instead of always re-synthing.
voiceQuote: p.voiceQuote || '',
voiceDesignUsed: p.voiceDesignUsed || '',
voiceIdUsed: p.voiceIdUsed || '',
// Portrait: the editable appearance prompt + what the cached image (IndexedDB) was
// made from, so a reload knows the image is current vs stale (re-generate it).
appearance: p.appearance || '',
portraitUsed: p.portraitUsed || '',
createdAt: now,
updatedAt: now,
}
const i = d.personas.findIndex((x) => x.id === id)
if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec }
else { d.personas.unshift(rec) }
write(d)
return rec
}
export function removePersona(id) {
const d = read()
const next = d.personas.filter((x) => x.id !== id)
if (next.length !== d.personas.length) write({ personas: next })
_del(STORE, id); _del(PSTORE, id)
}
// ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits'
let _dbp = null
function db() {
if (!_dbp) {
_dbp = new Promise((res, rej) => {
const r = indexedDB.open(DB, 2) // v2 adds the 'portraits' store alongside 'voices'
r.onupgradeneeded = () => {
const d = r.result
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
if (!d.objectStoreNames.contains(PSTORE)) d.createObjectStore(PSTORE)
}
r.onsuccess = () => res(r.result)
r.onerror = () => rej(r.error)
})
}
return _dbp
}
async function _put(store, id, blob) {
try {
const d = await db()
await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
} catch { /* best-effort */ }
}
async function _get(store, id) {
try {
const d = await db()
return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
} catch { return null }
}
async function _del(store, id) {
try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ }
}
export const putAudio = (id, blob) => _put(STORE, id, blob)
export const getAudio = (id) => _get(STORE, id)
export const putPortrait = (id, blob) => _put(PSTORE, id, blob)
export const getPortrait = (id) => _get(PSTORE, id)
|