Spaces:
Running
Running
| // 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) | |