tiny-army / web /heroCreator.js
polats's picture
Game: hero creation + selection flow; pin the Space
3ef6bd6
// Shared hero-creator — the recruit/stream/edit experience used by BOTH the Personas page
// (personaPanel.js, which adds a barracks roster around it) and the Game page's "Create a hero"
// modal (tiny.js). Renders the two-column `.persona-view` (class/seed/recruit controls on the left,
// streamed name/about/quote + voice + portrait on the right), runs the whole generation pipeline
// (local LLM → JSON persona, image engine → portrait, TTS → voice) and autosaves to the roster.
//
// const creator = mountHeroCreator(host, { extraControls, onSaved })
// creator.load(persona, { savedId }) // show an existing hero for editing
// creator.current() // { persona, savedId }
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
import { extractLivePersona } from '/web/personaStream.js'
import { parsePersonaJson } from '/web/personaParse.js'
import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
import {
createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange, ttsBackendLabel,
} from '/web/tts.js'
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
// Each class shows the idle pose of a fitting sprite (character slug → see
// characters.json). The sheet's front-right row is animated as a tiny looping
// icon beside the class name in the dropdown.
export const CLASS_SLUG = {
Warrior: 'true-heroes-iii-fighter',
Ranger: 'true-heroes-iii-ranger',
Monk: 'true-heroes-ii-bard',
Assassin: 'true-heroes-iv-ninja-assassin',
Mage: 'true-heroes-iii-wizard',
Paladin: 'true-heroes-ii-paladin',
Cleric: 'true-heroes-ii-cleric',
Knight: 'rts-humans-knight',
}
const ICON_PX = 30 // on-screen size of the class icon box
const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
function el(tag, props = {}, kids = []) {
const n = document.createElement(tag)
for (const [k, v] of Object.entries(props)) {
if (k === 'class') n.className = v
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
else if (v != null) n.setAttribute(k, v)
}
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
return n
}
// Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
// the front-right row (row 0) and step across the columns to loop the idle — no
// canvas, just a sized background + the Web Animations API.
export function animateIdleIcon(box, idleUrl, sizePx = ICON_PX) {
box.getAnimations?.().forEach((a) => a.cancel())
box.style.backgroundImage = ''
const img = new Image()
img.onload = () => {
const cell = (img.naturalHeight / 4) || img.naturalHeight
const cols = Math.max(1, Math.round(img.naturalWidth / cell))
const rows = Math.max(1, Math.round(img.naturalHeight / cell))
const cellPx = sizePx * ICON_ZOOM // zoomed on-screen cell size (fills the box)
const off = (cellPx - sizePx) / 2 // inset to centre the box on a cell
box.style.backgroundImage = `url("${idleUrl}")`
box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
const y = `-${off}px` // row 0, vertically centred
box.animate(
[{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
{ duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
)
}
img.src = idleUrl
}
// A class picker that mirrors a native <select> API (.value get/set, 'change'
// event) but renders an animated idle icon beside each class.
function makeClassDropdown(classes) {
const triggerIco = el('span', { class: 'persona-class-ico' })
const triggerLabel = el('span', { class: 'persona-classdrop-label' })
const trigger = el('button', { class: 'persona-input persona-classdrop-trigger', type: 'button' },
[triggerIco, triggerLabel, el('span', { class: 'persona-classdrop-chev' }, '▾')])
const menu = el('div', { class: 'persona-classdrop-menu' })
const root = el('div', { class: 'persona-classdrop' }, [trigger, menu])
let value = classes[0]
let icons = {} // class → idle sheet URL (filled by setIcons)
const optIco = {} // class → menu icon span
const items = classes.map((c) => {
const ico = el('span', { class: 'persona-class-ico' }); optIco[c] = ico
const it = el('button', { class: 'persona-classdrop-opt', type: 'button' }, [ico, el('span', {}, c)])
it.addEventListener('click', () => { set(c); close() })
return it
})
menu.append(...items)
function set(c) {
if (!classes.includes(c)) return
const changed = c !== value
value = c
triggerLabel.textContent = c
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === c))
if (icons[c]) animateIdleIcon(triggerIco, icons[c])
if (changed) root.dispatchEvent(new Event('change'))
}
const close = () => root.classList.remove('open')
trigger.addEventListener('click', (e) => { e.stopPropagation(); root.classList.toggle('open') })
document.addEventListener('click', (e) => { if (!root.contains(e.target)) close() })
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
root.setIcons = (map) => {
icons = map
for (const c of classes) if (map[c]) animateIdleIcon(optIco[c], map[c])
if (map[value]) animateIdleIcon(triggerIco, map[value])
}
Object.defineProperty(root, 'value', { get: () => value, set: (v) => set(v) })
triggerLabel.textContent = value
items.forEach((it, i) => it.classList.toggle('sel', classes[i] === value))
return root
}
// Resolve each class's idle sheet via characters.json and light up the dropdown.
async function loadClassIcons(dropdown) {
try {
const d = await fetch('/sprites/characters.json').then((r) => r.json())
const bySlug = {}
for (const p of d.packs || []) for (const c of p.characters || []) bySlug[c.slug] = c
const map = {}
for (const [cls, slug] of Object.entries(CLASS_SLUG)) {
const idle = bySlug[slug]?.idle
if (idle) map[cls] = spriteUrl(idle)
}
dropdown.setIcons(map)
} catch { /* no icons — the dropdown still works with labels only */ }
}
// Mount the creator into `host`. `opts.extraControls` (DOM) is appended into the controls aside
// (the Personas page passes its barracks roster here). `opts.onSaved(rec)` fires after every
// autosave (generate + inline edits + portrait/voice), so the host can refresh a roster, enable a
// "Save & Play" button, etc. Returns a controller (load / current / reset / stop).
export function mountHeroCreator(host, opts = {}) {
const DEFAULT_STATUS = 'Runs on your device — no cloud.'
const sel = makeClassDropdown(CLASSES)
loadClassIcons(sel)
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
const stats = el('div', { class: 'persona-stats' })
const status = el('div', { class: 'persona-status' }, DEFAULT_STATUS)
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
[el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
const voiceStatus = el('span', { class: 'persona-act-status' }) // "generating voice via …" beside ▶
const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
const portraitStatus = el('span', { class: 'persona-act-status' }) // "painting via …" beside 🎨
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
// The general status line + token rate live INSIDE the debug section (only per-action voice/portrait
// notes show beside their buttons). No copy button.
const thinkEl = el('pre', { class: 'persona-think' })
const thinkWrap = el('details', { class: 'persona-think-wrap' },
[el('summary', {}, 'model output / debug (raw)'), status, stats, thinkEl])
// Plain section header (top line + small heading) with an optional right-side action.
const secHead = (title, action) =>
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), action || el('span')])
// Header whose action is a create button with an inline status note to its RIGHT (full model name).
const actionHead = (title, button, note) =>
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), el('div', { class: 'persona-sec-action' }, [button, note])])
const backBtn = el('button', { class: 'persona-back', type: 'button' }, '← Back')
// Optional barracks roster (saved heroes) — always visible in the left column when enabled.
const rosterEl = opts.showBarracks ? el('div', { class: 'persona-roster' }) : null
const barracksEl = opts.showBarracks
? el('div', { class: 'persona-barracks' }, [el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl])
: null
// Left column has two states. STATE A (recruit): class + seed + Recruit. STATE B (a hero exists): a
// portrait panel — placeholder/image + 🎨 paint button & badge + the editable appearance prompt. The
// barracks (if any) and the model-output debug are shown in BOTH states; the debug is anchored bottom.
const recruitBox = el('div', { class: 'persona-recruit-box' }, [
el('label', { class: 'persona-label' }, 'Class'), sel,
el('label', { class: 'persona-label' }, 'Seed'), seed,
btn,
])
const portraitBox = el('div', { class: 'persona-portrait-panel' }, [
actionHead('Portrait', portraitBtn, portraitStatus),
portraitWrap,
appearanceEl,
])
const controls = el('aside', { class: 'persona-controls' }, [
recruitBox, portraitBox, barracksEl, thinkWrap,
])
const emptyEl = el('div', { class: 'persona-empty' }, opts.emptyText || 'Every legend starts here — pick a class and recruit your hero.')
const bodyEl = el('div', { class: 'persona-body' }, [
nameEl,
secHead('About'), aboutEl,
actionHead('Quote', playBtn, voiceStatus), quoteEl,
secHead('Voice design'), voiceEl, voicePickRow,
])
const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl])
const view = el('div', { class: 'persona-view' }, [controls, result])
host.appendChild(view)
// Back button: in the host-provided footer slot (the Game modal) or, by default, at the top of the
// portrait panel. It RESETS the creator and returns to the recruit state; only shown in STATE B.
if (opts.backSlot) opts.backSlot.prepend(backBtn)
else portraitBox.prepend(backBtn)
backBtn.addEventListener('click', () => resetAll())
function setLeftState(s) {
const portrait = s === 'portrait'
recruitBox.style.display = portrait ? 'none' : ''
portraitBox.style.display = portrait ? '' : 'none'
backBtn.style.display = portrait ? '' : 'none'
if (barracksEl) barracksEl.style.display = portrait ? 'none' : '' // barracks only in the recruit state
try { opts.onState?.(s) } catch { /* host hook */ }
}
setLeftState('recruit')
let lastPersona = null // the persona currently shown
let savedId = null // its roster id (set the moment it's shown — always saved)
let hasVoice = false // a cached voice file exists for this persona
let hasPortrait = false // a cached portrait exists for this persona
let portraitBusy = false
let portraitUrl = null // object URL for the shown image (revoked on replace)
let working = false
let busy = false
let playing = false // audio is currently sounding (▶ becomes ⏹)
const fireSaved = (rec) => { try { opts.onSaved?.(rec, { persona: lastPersona, savedId }) } catch { /* host hook */ } }
function setPlaying(on) {
playing = on
playBtn.classList.toggle('playing', on)
playBtn.textContent = on ? '⏹' : '▶'
if (on) playBtn.title = 'Stop'
else updateVoiceUI()
}
function stopVoice() { stopVoiceLive(); setPlaying(false) }
async function playBuf(arrayBuffer) {
setPlaying(true)
try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
finally { setPlaying(false) }
}
function refreshVisibility() {
const show = !!lastPersona || busy
bodyEl.style.display = show ? '' : 'none'
emptyEl.style.display = show ? 'none' : ''
}
refreshVisibility()
const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
const isDesign = () => activeEngineIsDesign()
const isNative = () => activeEngineIsNative()
const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
function updateVoiceUI() {
const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
playBtn.classList.toggle('badged', needs)
if (playing) return
playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
}
function refreshVoiceMode() {
const design = isDesign()
voiceEl.contentEditable = design ? 'true' : 'false'
voiceEl.classList.toggle('readonly', !design)
voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
voicePickRow.style.display = design ? 'none' : ''
if (!design) {
const voices = activeVoices()
voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
voicePickEl.value = cur
if (lastPersona) lastPersona.voiceId = cur
}
}
voicePickEl.addEventListener('change', () => {
if (!lastPersona) return
lastPersona.voiceId = voicePickEl.value
autosave(); updateVoiceUI()
})
onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
function autosave() {
if (!lastPersona) return
const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
savedId = rec.id
fireSaved(rec)
}
function editable(elm, field, { single = false } = {}) {
elm.contentEditable = 'true'
elm.spellcheck = false
if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
if (field === 'quote' || field === 'voice') {
elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
}
elm.addEventListener('blur', () => {
if (!lastPersona) return
lastPersona[field] = elm.textContent.trim()
autosave(); updateVoiceUI()
})
}
editable(nameEl, 'name', { single: true })
editable(aboutEl, 'about')
editable(quoteEl, 'quote', { single: true })
editable(voiceEl, 'voice')
// ── Portrait ──────────────────────────────────────────────────────────────
const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
const buildAppearance = (p) => [
[p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
(p.about || '').trim(),
].filter(Boolean).join('. ')
const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
function setPortrait(blob) {
if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
portraitUrl = blob ? URL.createObjectURL(blob) : null
portraitImg.src = portraitUrl || ''
portraitWrap.classList.toggle('has-img', !!blob)
}
function updatePortraitUI() {
// Paintable when there's no portrait yet, or the appearance changed since the last one. When the
// portrait already matches the definition, repainting is disabled (nothing to redo).
const paintable = !!lastPersona && (!hasPortrait || portraitDirty())
portraitBtn.classList.toggle('badged', paintable)
portraitBtn.disabled = portraitBusy || (!!lastPersona && !paintable)
portraitBtn.title = !lastPersona ? 'Paint portrait' : (!hasPortrait ? 'Paint portrait' : (portraitDirty() ? 'Repaint portrait' : 'Portrait is up to date'))
}
appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
async function makePortrait() {
if (portraitBusy || !lastPersona) return
if (hasPortrait && !portraitDirty()) return // no change to the appearance — don't repaint
autosave() // ensure an id to key the image
const appearance = appearanceFor(lastPersona)
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
try {
if (imageNeedsDownload()) {
portraitStatus.textContent = 'loading model…'
await ensureImage((f) => { portraitStatus.textContent = `downloading model… ${Math.round(f * 100)}%` })
}
portraitStatus.textContent = `painting via ${imageBackendLabel()}…`
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
await putPortrait(savedId, blob)
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
hasPortrait = true; setPortrait(blob); autosave()
portraitStatus.textContent = ''
} catch (e) { portraitStatus.textContent = `failed: ${e.message || e}` }
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
}
portraitBtn.addEventListener('click', makePortrait)
async function showPersona(p, o = {}) {
stopVoice() // picking another hero cuts the current voice
lastPersona = { ...p }
savedId = o.savedId || null
nameEl.textContent = p.name || ''
aboutEl.textContent = p.about || ''
quoteEl.textContent = p.quote || ''
voiceEl.textContent = p.voice || ''
appearanceEl.textContent = p.appearance || buildAppearance(p)
hasVoice = savedId ? !!(await getAudio(savedId)) : false
let pblob = null
hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
setPortrait(pblob)
refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
setLeftState('portrait') // a hero exists → show the portrait panel in place of class/recruit
}
async function play() {
if (playing) { stopVoice(); return }
if (working || !lastPersona) return
const line = lineFor(lastPersona)
if (isNative()) {
setPlaying(true)
try { await speakVoiceLive(lastPersona.voiceId || '', line) }
catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
finally { setPlaying(false) }
return
}
if (hasVoice && !isDirty()) {
const blob = savedId ? await getAudio(savedId) : null
if (blob) { await playBuf(await blob.arrayBuffer()); return }
hasVoice = false
}
if (isDesign() && !lastPersona.voice) { voiceStatus.textContent = 'add a voice design first'; return }
autosave() // ensure an id to key the audio
const design = isDesign()
const reclone = design && hasVoice && !designChanged()
working = true; playBtn.classList.add('busy'); playBtn.disabled = true
const verb = reclone ? 'updating' : (design ? 'designing' : 'generating')
voiceStatus.textContent = `${verb} voice via ${ttsBackendLabel()}…`
let wav = null
try {
if (design && reclone) {
const blob = await getAudio(savedId)
wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
} else if (design) {
wav = await createVoiceWav(lastPersona.voice, line)
} else {
wav = await synthVoiceWav(lastPersona.voiceId || '', line)
}
await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
lastPersona.voiceQuote = line
lastPersona.voiceDesignUsed = lastPersona.voice || ''
lastPersona.voiceIdUsed = lastPersona.voiceId || ''
hasVoice = true; autosave()
voiceStatus.textContent = ''
} catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
if (wav) await playBuf(wav.slice(0))
}
playBtn.addEventListener('click', play)
try {
new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) { if (playing) stopVoice() }
else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() }
}
}).observe(host)
} catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
// ← Back: wipe every field and return to a blank recruit state.
function resetAll() {
stopVoice()
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
nameEl.textContent = ''; aboutEl.textContent = ''; quoteEl.textContent = ''
voiceEl.textContent = ''; appearanceEl.textContent = ''
setPortrait(null)
voiceStatus.textContent = ''; portraitStatus.textContent = ''
stats.textContent = ''; status.textContent = DEFAULT_STATUS; thinkEl.textContent = ''; thinkWrap.open = false
updateVoiceUI(); updatePortraitUI(); refreshVisibility(); setLeftState('recruit')
}
// ── Barracks (saved heroes) — clicking one loads it for editing; always visible when enabled. ──
function renderRoster(personas) {
if (!rosterEl) return
if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
rosterEl.replaceChildren(...personas.map((p) =>
el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
`${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
])))
}
if (opts.showBarracks) { renderRoster(listPersonas()); onRosterChange(renderRoster) }
async function generate() {
if (busy) return
busy = true; btn.disabled = true
setLeftState('portrait') // swap the left column to the portrait panel the moment we start
refreshVisibility()
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
nameEl.textContent = '…'; aboutEl.textContent = ''
quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
stopVoice(); setPortrait(null); voiceStatus.textContent = ''; portraitStatus.textContent = ''; updateVoiceUI(); updatePortraitUI()
thinkEl.textContent = ''; stats.textContent = '' // debug stays COLLAPSED — status/tokens update inside it
let acc = ''
try {
status.textContent = `loading ${currentModel().label}…`
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}${Math.round(frac * 100)}% (one-time)` })
status.textContent = `writing on your device with ${currentModel().label}…`
await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
maxTokens: MAX_TOKENS,
onToken: (piece) => {
acc += piece
thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
const live = extractLivePersona(stripThink(acc))
if (live.name) nameEl.textContent = live.name
if (live.about) aboutEl.textContent = live.about
},
onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
})
try {
const p = parsePersonaJson(stripThinkFinal(acc))
await showPersona(p)
autosave() // generated personas are saved immediately (no Save button)
status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
} catch (e) {
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e})`
setLeftState('recruit') // back to recruit so they can retry
}
} catch (e) {
status.textContent = `couldn't run the local model: ${e.message || e}`
setLeftState('recruit')
} finally { busy = false; btn.disabled = false; refreshVisibility() }
}
btn.addEventListener('click', generate)
return {
root: view,
load: (p, o = {}) => showPersona(p, o),
current: () => ({ persona: lastPersona, savedId }),
reset: resetAll,
stop: stopVoice,
}
}