Spaces:
Running
Running
File size: 27,386 Bytes
3ef6bd6 | 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 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 | // 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,
}
}
|