tiny-army / web /diaryPanel.js
polats's picture
Personas → heroes: shield icon, hero terminology, empty state, active highlight
29474fc
// War-diary panel — vanilla DOM, mounted into #diary-stage. Streams a first-person
// diary entry generated ON THE USER'S DEVICE via the LLM facade, and can READ IT ALOUD
// on the user's device too (Kokoro / Kitten / Web Speech via the TTS facade). Shares
// the persona styling (.persona-*), the model picker, and tok/s stats.
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
import { makeNarrator, ensureTts, setVoiceDescription, getAutoNarrate } from '/web/tts.js'
import { DIARY_SYSTEM, diaryUserPrompt, stripThinkFinal, noThink } from '/web/personaPrompts.js'
const MAX_TOKENS = 100 // short diary entries — cap matches the "~60 words" prompt
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
}
export function mountDiaryPanel(host) {
const unit = el('input', { class: 'persona-input', type: 'text', value: 'Bram the Warrior' })
const traits = el('input', { class: 'persona-input', type: 'text', value: 'Cautious, Veteran, Vengeful' })
const stats = el('div', { class: 'persona-stats' })
const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
const btn = el('button', { class: 'persona-go', type: 'button' }, '✒ Write war diary')
const narrateBtn = el('button', { class: 'persona-go persona-go-alt', type: 'button' }, '🔊 Read aloud')
const ttsStatus = el('div', { class: 'persona-status tts-status' })
const out = el('div', { class: 'persona-about' }, 'A first-person diary entry, written by a small model in your browser — and read aloud on your device.')
const dbgEl = el('pre', { class: 'persona-think' })
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output / debug (raw)'), copyBtn, dbgEl])
const controls = el('aside', { class: 'persona-controls' }, [
el('label', { class: 'persona-label' }, 'Unit'), unit,
el('label', { class: 'persona-label' }, 'Traits'), traits,
btn, stats, status,
narrateBtn, ttsStatus,
])
const result = el('div', { class: 'persona-result' }, [out, dbgWrap])
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
let lastDebug = ''
function buildDebug(outcome, raw) {
return [
'=== TINY ARMY · DIARY DEBUG ===',
`engine: ${getEngineId()} · ${backendLabel()}`,
`model: ${currentModelId()} (${currentModel().label})`,
`input: unit=${unit.value} traits=${traits.value} maxTokens=${MAX_TOKENS}`,
`outcome: ${outcome}`,
`--- raw output (${(raw || '').length} chars) ---`,
raw || '(empty)',
].join('\n')
}
copyBtn.addEventListener('click', async () => {
const text = lastDebug || buildDebug('(no generation yet)', '')
try {
await navigator.clipboard.writeText(text)
copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
} catch {
dbgEl.textContent = text; dbgWrap.open = true
const r = document.createRange(); r.selectNodeContents(dbgEl)
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
}
})
let busy = false
let lastBody = '' // diary text (no header), what gets narrated
let narrator = null
let speaking = false
function setSpeaking(on) {
speaking = on
narrateBtn.textContent = on ? '⏹ Stop reading' : '🔊 Read aloud'
}
function stopNarration() { if (narrator) narrator.stop() }
// Ensure the TTS model is loaded (showing progress), then return a fresh narrator.
async function makeReadyNarrator() {
// For Qwen3-TTS's "persona voice", design a voice from this unit's traits.
setVoiceDescription(`A war-weary hero's voice — ${(traits.value || 'battle-hardened').trim()}.`)
ttsStatus.textContent = 'loading voice…'
await ensureTts((frac) => { ttsStatus.textContent = `downloading voice… ${Math.round(frac * 100)}% (one-time)` })
ttsStatus.textContent = 'reading on your device…'
return makeNarrator({
onState: (s) => {
if (s === 'done' || s === 'stopped') {
setSpeaking(false)
ttsStatus.textContent = s === 'stopped' ? 'stopped' : 'done reading ✓'
}
},
})
}
async function readAloud() {
if (speaking) { stopNarration(); return }
if (!lastBody.trim()) { ttsStatus.textContent = 'nothing to read yet — write a diary first'; return }
narrateBtn.disabled = true
try {
setSpeaking(true)
narrator = await makeReadyNarrator()
narrator.push(lastBody)
narrator.end()
} catch (e) {
setSpeaking(false)
ttsStatus.textContent = `couldn't read aloud: ${e.message || e}`
} finally {
narrateBtn.disabled = false
}
}
async function write() {
if (busy) return
busy = true; btn.disabled = true; stats.textContent = ''
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
stopNarration()
const header = `— Diary of ${(unit.value || 'a nameless hero').trim()} —\n\n`
out.textContent = header
lastBody = ''
// If auto-narrate is on, prepare a live narrator before generation starts.
let live = null
let spokenLen = 0
let raw = ''
if (getAutoNarrate()) {
try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
}
try {
status.textContent = `loading ${currentModel().label} into your browser…`
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(DIARY_SYSTEM, diaryUserPrompt(unit.value, traits.value) + noThink(currentModelId()), {
maxTokens: MAX_TOKENS, temperature: 0.9,
onToken: (piece) => {
raw += piece
lastBody = stripThinkFinal(raw)
out.textContent = header + lastBody
if (live) { const delta = lastBody.slice(spokenLen); if (delta) { live.push(delta); spokenLen = lastBody.length } }
},
onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
})
// Final pass: keep the answer even if the model left a <think> unclosed (which the
// live streaming strip would have emptied).
const finalBody = stripThinkFinal(raw)
if (finalBody && finalBody !== lastBody) { lastBody = finalBody; out.textContent = header + lastBody }
status.textContent = 'written ✓ (generated locally)'
lastDebug = buildDebug(lastBody.trim() ? 'written OK' : 'EMPTY OUTPUT', raw)
dbgEl.textContent = raw
if (live) live.end() // flush the tail sentence; onState resets the button
} catch (e) {
status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), raw)
dbgEl.textContent = raw; dbgWrap.open = true
if (live) live.stop()
} finally {
busy = false; btn.disabled = false
}
}
btn.addEventListener('click', write)
narrateBtn.addEventListener('click', readAloud)
}