Spaces:
Running
Running
Personas-as-agents: spoken quote + save to a local-first barracks roster
Browse filesEach persona now also gets a `quote` (a short spoken line). "Hear voice" designs the
voice and speaks the QUOTE (not the bio). New personaStore.js — a local-first roster
(localStorage JSON blob + CRUD + change listeners + a pluggable sync hook), modeled on
woid's Shelter store — so saved soldiers persist for returning visitors. Persona panel
gains "💾 Save to barracks" + a roster list (view / remove); MAX_TOKENS 160→200 for the
extra fields. Verified: save → reload → persists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +70 -38
- web/personaParse.js +2 -1
- web/personaPrompts.js +3 -1
- web/personaStore.js +59 -0
- web/shell/persona.css +27 -1
web/personaPanel.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
-
// Tiny Army persona panel —
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 6 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 7 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 8 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 9 |
import { previewVoice, stopPreview } from '/web/tts.js'
|
|
|
|
| 10 |
|
| 11 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 12 |
-
const MAX_TOKENS =
|
| 13 |
|
| 14 |
function el(tag, props = {}, kids = []) {
|
| 15 |
const n = document.createElement(tag)
|
|
@@ -28,12 +29,15 @@ export function mountPersonaPanel(host) {
|
|
| 28 |
const stats = el('div', { class: 'persona-stats' })
|
| 29 |
const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
|
| 30 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
|
|
|
|
| 31 |
|
| 32 |
const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
|
| 33 |
const tagsEl = el('div', { class: 'persona-tags' })
|
| 34 |
-
const
|
|
|
|
| 35 |
const voiceEl = el('div', { class: 'persona-voice-desc' })
|
| 36 |
const hearBtn = el('button', { class: 'persona-go persona-go-alt persona-hear', type: 'button', style: 'display:none' }, '🔊 Hear voice')
|
|
|
|
| 37 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 38 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 39 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
@@ -43,37 +47,69 @@ export function mountPersonaPanel(host) {
|
|
| 43 |
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 44 |
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 45 |
btn, stats, status,
|
|
|
|
| 46 |
])
|
| 47 |
-
const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, aboutEl, voiceEl, hearBtn, thinkWrap])
|
| 48 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
let lastPersona = null
|
| 53 |
let hearing = false
|
| 54 |
-
hearBtn.addEventListener('click', async () => {
|
| 55 |
-
if (hearing) { stopPreview(); return } // onfinish below resets the label
|
| 56 |
-
if (!lastPersona || !lastPersona.voice) return
|
| 57 |
-
hearing = true; hearBtn.textContent = '⏹ Stop'
|
| 58 |
-
const prev = status.textContent
|
| 59 |
-
status.textContent = '☁ designing the voice on DashScope…'
|
| 60 |
-
try {
|
| 61 |
-
await previewVoice(lastPersona.voice, lastPersona.about || lastPersona.name || 'Hello.')
|
| 62 |
-
status.textContent = prev
|
| 63 |
-
} catch (e) {
|
| 64 |
-
status.textContent = `voice failed: ${e.message || e}`
|
| 65 |
-
} finally {
|
| 66 |
-
hearing = false; hearBtn.textContent = '🔊 Hear voice'
|
| 67 |
-
}
|
| 68 |
-
})
|
| 69 |
|
| 70 |
function setTags(p) {
|
| 71 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 72 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
| 73 |
}
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
|
|
|
|
|
|
| 77 |
|
| 78 |
// A self-contained, paste-ready report of the last run.
|
| 79 |
let lastDebug = ''
|
|
@@ -98,7 +134,6 @@ export function mountPersonaPanel(host) {
|
|
| 98 |
copyBtn.textContent = '✓ copied'
|
| 99 |
setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
|
| 100 |
} catch {
|
| 101 |
-
// Clipboard blocked (insecure context / permissions) — show it selected to copy by hand.
|
| 102 |
thinkEl.textContent = text; thinkWrap.open = true
|
| 103 |
const r = document.createRange(); r.selectNodeContents(thinkEl)
|
| 104 |
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
|
@@ -112,34 +147,31 @@ export function mountPersonaPanel(host) {
|
|
| 112 |
busy = true; btn.disabled = true
|
| 113 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 114 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 115 |
-
|
|
|
|
| 116 |
if (hearing) stopPreview()
|
| 117 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 118 |
let acc = ''
|
| 119 |
try {
|
| 120 |
-
status.textContent = `loading ${currentModel().label}
|
| 121 |
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 122 |
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 123 |
await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
|
| 124 |
maxTokens: MAX_TOKENS,
|
| 125 |
onToken: (piece) => {
|
| 126 |
acc += piece
|
| 127 |
-
thinkEl.textContent = acc
|
| 128 |
thinkEl.scrollTop = thinkEl.scrollHeight
|
| 129 |
const live = extractLivePersona(stripThink(acc))
|
| 130 |
if (live.name) nameEl.textContent = live.name
|
| 131 |
if (live.about) aboutEl.textContent = live.about
|
| 132 |
},
|
| 133 |
-
onStats:
|
| 134 |
})
|
| 135 |
try {
|
| 136 |
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
setTags(p)
|
| 140 |
-
lastPersona = p
|
| 141 |
-
if (p.voice) { voiceEl.textContent = `🎙 ${p.voice}`; hearBtn.style.display = '' }
|
| 142 |
-
status.textContent = 'enlisted ✓ (generated locally)'
|
| 143 |
lastDebug = buildDebug('parsed OK', acc)
|
| 144 |
thinkWrap.open = false
|
| 145 |
} catch (e) {
|
|
|
|
| 1 |
+
// Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a soldier
|
| 2 |
+
// (name/about/traits + a voice design + a spoken quote) on the chosen engine, lets you
|
| 3 |
+
// HEAR their voice say their quote, and SAVE them to a local-first barracks roster
|
| 4 |
+
// (personaStore) so they persist for returning visitors. Modeled on woid's agent store.
|
| 5 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 6 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 7 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 8 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 9 |
import { previewVoice, stopPreview } from '/web/tts.js'
|
| 10 |
+
import { listPersonas, savePersona, removePersona, onRosterChange } from '/web/personaStore.js'
|
| 11 |
|
| 12 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 13 |
+
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
| 14 |
|
| 15 |
function el(tag, props = {}, kids = []) {
|
| 16 |
const n = document.createElement(tag)
|
|
|
|
| 29 |
const stats = el('div', { class: 'persona-stats' })
|
| 30 |
const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
|
| 31 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
|
| 32 |
+
const rosterEl = el('div', { class: 'persona-roster' })
|
| 33 |
|
| 34 |
const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
|
| 35 |
const tagsEl = el('div', { class: 'persona-tags' })
|
| 36 |
+
const quoteEl = el('blockquote', { class: 'persona-quote' })
|
| 37 |
+
const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small model writes their legend, designs their voice, and gives them a battle-cry.')
|
| 38 |
const voiceEl = el('div', { class: 'persona-voice-desc' })
|
| 39 |
const hearBtn = el('button', { class: 'persona-go persona-go-alt persona-hear', type: 'button', style: 'display:none' }, '🔊 Hear voice')
|
| 40 |
+
const saveBtn = el('button', { class: 'persona-go persona-save', type: 'button', style: 'display:none' }, '💾 Save to barracks')
|
| 41 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 42 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 43 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
|
|
| 47 |
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 48 |
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 49 |
btn, stats, status,
|
| 50 |
+
el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
|
| 51 |
])
|
| 52 |
+
const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, quoteEl, aboutEl, voiceEl, el('div', { class: 'persona-actions' }, [hearBtn, saveBtn]), thinkWrap])
|
| 53 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 54 |
|
| 55 |
+
let lastPersona = null // the persona currently shown (generated or loaded)
|
| 56 |
+
let savedId = null // its roster id once saved
|
|
|
|
| 57 |
let hearing = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
function setTags(p) {
|
| 60 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 61 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
| 62 |
}
|
| 63 |
+
|
| 64 |
+
// Show a persona (from generation or the roster) in the result pane.
|
| 65 |
+
function showPersona(p, opts = {}) {
|
| 66 |
+
lastPersona = p
|
| 67 |
+
savedId = opts.savedId || null
|
| 68 |
+
nameEl.textContent = p.name || 'Soldier'
|
| 69 |
+
setTags(p)
|
| 70 |
+
quoteEl.textContent = p.quote ? `“${p.quote}”` : ''
|
| 71 |
+
aboutEl.textContent = p.about || ''
|
| 72 |
+
voiceEl.textContent = p.voice ? `🎙 ${p.voice}` : ''
|
| 73 |
+
hearBtn.style.display = p.voice ? '' : 'none'
|
| 74 |
+
saveBtn.style.display = ''
|
| 75 |
+
saveBtn.textContent = savedId ? '✓ saved' : '💾 Save to barracks'
|
| 76 |
+
saveBtn.disabled = !!savedId
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 🔊 Hear voice — design the voice from `voice` and speak the QUOTE (falls back to a
|
| 80 |
+
// line built from name/about if there's no quote). Networked (Qwen3-TTS).
|
| 81 |
+
async function hear() {
|
| 82 |
+
if (hearing) { stopPreview(); return }
|
| 83 |
+
if (!lastPersona || !lastPersona.voice) return
|
| 84 |
+
const line = lastPersona.quote || lastPersona.about || `${lastPersona.name} reporting for duty.`
|
| 85 |
+
hearing = true; hearBtn.textContent = '⏹ Stop'
|
| 86 |
+
const prev = status.textContent
|
| 87 |
+
status.textContent = 'designing the voice…'
|
| 88 |
+
try { await previewVoice(lastPersona.voice, line); status.textContent = prev }
|
| 89 |
+
catch (e) { status.textContent = `voice failed: ${e.message || e}` }
|
| 90 |
+
finally { hearing = false; hearBtn.textContent = '🔊 Hear voice' }
|
| 91 |
+
}
|
| 92 |
+
hearBtn.addEventListener('click', hear)
|
| 93 |
+
|
| 94 |
+
saveBtn.addEventListener('click', () => {
|
| 95 |
+
if (!lastPersona || savedId) return
|
| 96 |
+
const rec = savePersona({ ...lastPersona, unitClass: sel.value, seed: seed.value })
|
| 97 |
+
savedId = rec.id
|
| 98 |
+
saveBtn.textContent = '✓ saved'; saveBtn.disabled = true
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
// ── Barracks roster (saved soldiers) ──────────────────────────────────────
|
| 102 |
+
function renderRoster(personas) {
|
| 103 |
+
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No soldiers saved yet.')); return }
|
| 104 |
+
rosterEl.replaceChildren(...personas.map((p) =>
|
| 105 |
+
el('div', { class: 'persona-roster-item' }, [
|
| 106 |
+
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }) },
|
| 107 |
+
`${p.name}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
|
| 108 |
+
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
|
| 109 |
+
])))
|
| 110 |
}
|
| 111 |
+
renderRoster(listPersonas())
|
| 112 |
+
onRosterChange(renderRoster)
|
| 113 |
|
| 114 |
// A self-contained, paste-ready report of the last run.
|
| 115 |
let lastDebug = ''
|
|
|
|
| 134 |
copyBtn.textContent = '✓ copied'
|
| 135 |
setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
|
| 136 |
} catch {
|
|
|
|
| 137 |
thinkEl.textContent = text; thinkWrap.open = true
|
| 138 |
const r = document.createRange(); r.selectNodeContents(thinkEl)
|
| 139 |
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
|
|
|
| 147 |
busy = true; btn.disabled = true
|
| 148 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 149 |
nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
|
| 150 |
+
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 151 |
+
hearBtn.style.display = 'none'; saveBtn.style.display = 'none'; lastPersona = null; savedId = null
|
| 152 |
if (hearing) stopPreview()
|
| 153 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 154 |
let acc = ''
|
| 155 |
try {
|
| 156 |
+
status.textContent = `loading ${currentModel().label}…`
|
| 157 |
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 158 |
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 159 |
await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
|
| 160 |
maxTokens: MAX_TOKENS,
|
| 161 |
onToken: (piece) => {
|
| 162 |
acc += piece
|
| 163 |
+
thinkEl.textContent = acc
|
| 164 |
thinkEl.scrollTop = thinkEl.scrollHeight
|
| 165 |
const live = extractLivePersona(stripThink(acc))
|
| 166 |
if (live.name) nameEl.textContent = live.name
|
| 167 |
if (live.about) aboutEl.textContent = live.about
|
| 168 |
},
|
| 169 |
+
onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
|
| 170 |
})
|
| 171 |
try {
|
| 172 |
const p = parsePersonaJson(stripThinkFinal(acc))
|
| 173 |
+
showPersona(p)
|
| 174 |
+
status.textContent = 'enlisted ✓ — 💾 Save to keep'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
lastDebug = buildDebug('parsed OK', acc)
|
| 176 |
thinkWrap.open = false
|
| 177 |
} catch (e) {
|
web/personaParse.js
CHANGED
|
@@ -96,6 +96,7 @@ export function parsePersonaJson(raw) {
|
|
| 96 |
const specialty = trimTag(parsed.specialty ?? parsed.role ?? parsed.job ?? null);
|
| 97 |
const personality = trimTag(parsed.personality ?? parsed.personalityTag ?? null);
|
| 98 |
const voice = (typeof parsed.voice === "string" ? parsed.voice.trim() : "").slice(0, 300);
|
|
|
|
| 99 |
|
| 100 |
-
return { name: name || null, about, avatar_hint, vibe, specialty, personality, voice };
|
| 101 |
}
|
|
|
|
| 96 |
const specialty = trimTag(parsed.specialty ?? parsed.role ?? parsed.job ?? null);
|
| 97 |
const personality = trimTag(parsed.personality ?? parsed.personalityTag ?? null);
|
| 98 |
const voice = (typeof parsed.voice === "string" ? parsed.voice.trim() : "").slice(0, 300);
|
| 99 |
+
const quote = (typeof parsed.quote === "string" ? parsed.quote.trim() : "").slice(0, 200);
|
| 100 |
|
| 101 |
+
return { name: name || null, about, avatar_hint, vibe, specialty, personality, voice, quote };
|
| 102 |
}
|
web/personaPrompts.js
CHANGED
|
@@ -12,7 +12,9 @@ export const PERSONA_SYSTEM =
|
|
| 12 |
' "vibe": a 1-3 word vibe,\n' +
|
| 13 |
' "voice": one sentence describing how they SOUND for a text-to-speech voice — gender, ' +
|
| 14 |
'age, pitch, accent, texture, pace and emotion (e.g. "a gravelly, battle-worn male ' +
|
| 15 |
-
'baritone, slow and weary, with a faint highland accent")
|
|
|
|
|
|
|
| 16 |
'Output strictly valid JSON. No preamble, no code fences, no commentary.'
|
| 17 |
|
| 18 |
export const DIARY_SYSTEM =
|
|
|
|
| 12 |
' "vibe": a 1-3 word vibe,\n' +
|
| 13 |
' "voice": one sentence describing how they SOUND for a text-to-speech voice — gender, ' +
|
| 14 |
'age, pitch, accent, texture, pace and emotion (e.g. "a gravelly, battle-worn male ' +
|
| 15 |
+
'baritone, slow and weary, with a faint highland accent"),\n' +
|
| 16 |
+
' "quote": one short punchy line they say aloud — a battle-cry or wry remark, ' +
|
| 17 |
+
'first person, under 15 words.\n' +
|
| 18 |
'Output strictly valid JSON. No preamble, no code fences, no commentary.'
|
| 19 |
|
| 20 |
export const DIARY_SYSTEM =
|
web/personaStore.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Local-first roster of saved soldiers (personas-as-agents). Modeled on woid's Shelter
|
| 2 |
+
// store: a single JSON blob in localStorage, simple CRUD, change listeners, and a
|
| 3 |
+
// pluggable `sync` hook so a backend can push/pull later WITHOUT changing callers.
|
| 4 |
+
// Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio
|
| 5 |
+
// blob and cross-device sync are the backend's job (see the plan). Future persona data
|
| 6 |
+
// (stats, avatar, xp, relationships) just adds fields to the record.
|
| 7 |
+
const KEY = 'tinyarmy.roster.v1'
|
| 8 |
+
|
| 9 |
+
const listeners = new Set()
|
| 10 |
+
let _sync = null // optional { push(records), pull() } — wired to a backend later
|
| 11 |
+
|
| 12 |
+
function read() {
|
| 13 |
+
try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } }
|
| 14 |
+
catch { return { personas: [] } }
|
| 15 |
+
}
|
| 16 |
+
function write(d) {
|
| 17 |
+
try { localStorage.setItem(KEY, JSON.stringify(d)) } catch { /* quota / disabled */ }
|
| 18 |
+
for (const fn of listeners) { try { fn(d.personas) } catch { /* ignore */ } }
|
| 19 |
+
if (_sync && _sync.push) { try { _sync.push(d.personas) } catch { /* best-effort */ } }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
|
| 23 |
+
|
| 24 |
+
export function listPersonas() { return read().personas }
|
| 25 |
+
export function getPersona(id) { return read().personas.find((p) => p.id === id) || null }
|
| 26 |
+
export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
| 27 |
+
export function setSync(sync) { _sync = sync }
|
| 28 |
+
|
| 29 |
+
// Insert or update. Returns the stored record (with id + timestamps).
|
| 30 |
+
export function savePersona(p) {
|
| 31 |
+
const d = read()
|
| 32 |
+
const now = Date.now()
|
| 33 |
+
const id = p.id || newId()
|
| 34 |
+
const rec = {
|
| 35 |
+
id,
|
| 36 |
+
name: p.name || 'Unnamed soldier',
|
| 37 |
+
unitClass: p.unitClass || '',
|
| 38 |
+
about: p.about || '',
|
| 39 |
+
quote: p.quote || '',
|
| 40 |
+
voice: p.voice || '',
|
| 41 |
+
specialty: p.specialty || '',
|
| 42 |
+
personality: p.personality || '',
|
| 43 |
+
vibe: p.vibe || '',
|
| 44 |
+
seed: p.seed || '',
|
| 45 |
+
createdAt: now,
|
| 46 |
+
updatedAt: now,
|
| 47 |
+
}
|
| 48 |
+
const i = d.personas.findIndex((x) => x.id === id)
|
| 49 |
+
if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec }
|
| 50 |
+
else { d.personas.unshift(rec) }
|
| 51 |
+
write(d)
|
| 52 |
+
return rec
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function removePersona(id) {
|
| 56 |
+
const d = read()
|
| 57 |
+
const next = d.personas.filter((x) => x.id !== id)
|
| 58 |
+
if (next.length !== d.personas.length) write({ personas: next })
|
| 59 |
+
}
|
web/shell/persona.css
CHANGED
|
@@ -67,7 +67,33 @@
|
|
| 67 |
max-width: 60ch; margin-top: 14px; font-style: italic;
|
| 68 |
}
|
| 69 |
.persona-voice-desc:empty { display: none; }
|
| 70 |
-
.persona-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
/* ── Model picker + cache controls ─────────────────────────────────────────── */
|
| 73 |
.model-bar { display: flex; flex-direction: column; gap: 4px; padding-bottom: 10px; margin-bottom: 6px; border-bottom: 1px dashed var(--p-ink); }
|
|
|
|
| 67 |
max-width: 60ch; margin-top: 14px; font-style: italic;
|
| 68 |
}
|
| 69 |
.persona-voice-desc:empty { display: none; }
|
| 70 |
+
.persona-quote {
|
| 71 |
+
margin: 14px 0 0; padding: 8px 0 8px 16px; border-left: 3px solid var(--p-transmit);
|
| 72 |
+
font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-style: italic;
|
| 73 |
+
line-height: 1.35; color: var(--p-ink); max-width: 56ch;
|
| 74 |
+
}
|
| 75 |
+
.persona-quote:empty { display: none; }
|
| 76 |
+
.persona-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
| 77 |
+
.persona-hear, .persona-save { display: inline-block; margin-top: 0; }
|
| 78 |
+
|
| 79 |
+
/* ── Barracks roster (saved soldiers) ──────────────────────────────────────── */
|
| 80 |
+
.persona-roster-label { margin-top: 18px; }
|
| 81 |
+
.persona-roster { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; max-height: 230px; overflow-y: auto; }
|
| 82 |
+
.persona-roster-empty { font-family: var(--p-mono); font-size: 10px; color: var(--p-muted); padding: 4px 0; }
|
| 83 |
+
.persona-roster-item { display: flex; align-items: stretch; gap: 4px; }
|
| 84 |
+
.persona-roster-name {
|
| 85 |
+
flex: 1; text-align: left; cursor: pointer;
|
| 86 |
+
font-family: var(--p-sans) !important; font-size: 13px !important; color: var(--p-ink) !important;
|
| 87 |
+
background: var(--p-card) !important; border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important;
|
| 88 |
+
padding: 7px 9px !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 89 |
+
}
|
| 90 |
+
.persona-roster-name:hover { background: var(--p-paper-2) !important; }
|
| 91 |
+
.persona-roster-x {
|
| 92 |
+
cursor: pointer; flex-shrink: 0; font-size: 11px !important;
|
| 93 |
+
color: var(--p-transmit) !important; background: var(--p-card) !important;
|
| 94 |
+
border: 1.5px solid var(--p-transmit) !important; border-radius: 0 !important; padding: 0 8px !important;
|
| 95 |
+
}
|
| 96 |
+
.persona-roster-x:hover { background: var(--p-transmit) !important; }
|
| 97 |
|
| 98 |
/* ── Model picker + cache controls ─────────────────────────────────────────── */
|
| 99 |
.model-bar { display: flex; flex-direction: column; gap: 4px; padding-bottom: 10px; margin-bottom: 6px; border-bottom: 1px dashed var(--p-ink); }
|