tiny-army / web /characterChat.js
polats's picture
Chat voice loading indicator + rebuild combat gating/feedback bundles
f1fdeff
Raw
History Blame Contribute Delete
8.34 kB
// Character chat — talk to a fighter on its character page. Streams replies from the local
// text model (runtime.js facade) using woid-style assembled context (characterContext.js), so
// the fighter answers in-voice and aware of its real game state. Each reply gets a ▶ button
// that speaks it in the fighter's voice (tts.js), caching the WAV per-message (Phase 4).
import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
import { buildContext } from '/web/characterContext.js'
import { getPersona, patchPersona, putChatAudio, getChatAudio, getAudio } from '/web/personaStore.js'
import {
ensureTts, createVoiceWav, cloneVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
stopPreview, stopVoiceLive,
} from '/web/tts.js'
const msgId = () => 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const HISTORY_CAP = 50
const SPEAKER = '🔊', STOP = '⏹', LOADING = '⏳'
export function mountCharacterChat(host, personaId, { onUpdate, getLive } = {}) {
const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
const thread = document.createElement('div'); thread.style.cssText = 'flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:4px 2px 8px'
const form = document.createElement('div'); form.style.cssText = 'display:flex;gap:8px;align-items:flex-end;border-top:1px solid #232b36;padding-top:8px'
const input = document.createElement('textarea'); input.rows = 1; input.placeholder = 'say something…'
input.style.cssText = 'flex:1;resize:none;max-height:96px;background:#0d1015;border:1px solid #2a3340;border-radius:10px;color:#e8e8e8;padding:9px 11px;font:13px var(--tac-font,system-ui);box-sizing:border-box'
const send = document.createElement('button'); send.type = 'button'; send.textContent = '➤'
send.style.cssText = 'flex:none;width:42px;height:40px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font-size:16px;cursor:pointer'
form.append(input, send); wrap.append(thread, form); host.append(wrap)
// Voice playback with a real stop. Only one line plays at a time; tapping the speaker while it's
// playing STOPS it (stopAudio/stopVoiceLive — same primitives the creation screen uses), and the
// WAV is cached per message for instant replay.
let playing = null // { id, btn }
const stopAll = () => {
try { stopVoiceLive() } catch { /* ignore */ }
try { stopPreview() } catch { /* ignore */ }
if (playing && playing.btn) playing.btn.textContent = SPEAKER
playing = null
}
async function speak(id, text, p, btn) {
if (playing && playing.id === id) { stopAll(); return } // tap the playing one → stop (toggle off)
stopAll() // stop any other line first
playing = { id, btn }; btn.textContent = LOADING // ⏳ while preparing/generating the voice
const toStop = () => { if (playing && playing.id === id) btn.textContent = STOP }
try {
if (activeEngineIsNative()) { await ensureTts(); toStop(); await speakVoiceLive(p.voiceId || currentVoiceId(), text) }
else {
let bytes
const blob = await getChatAudio(id)
if (blob) bytes = await blob.arrayBuffer()
else {
await ensureTts()
let wav
if (activeEngineIsDesign()) {
// Clone from the character's CREATED voice (the cached reference from the creation
// screen) so every chat line shares one consistent voice instead of re-designing
// (which drifts). Same path heroCreator uses; falls back to design if no reference yet.
const ref = await getAudio(p.id)
wav = ref ? await cloneVoiceWav(await ref.arrayBuffer(), p.voiceQuote || '', text, p.voice || '')
: await createVoiceWav(p.voice || '', text)
} else {
wav = await synthVoiceWav(p.voiceId || currentVoiceId(), text)
}
bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
}
if (playing && playing.id === id) { toStop(); await playWav(bytes.slice(0)) } // ⏹ once actually playing; skip if stopped during synth
}
} catch { /* voice is best-effort */ } finally { if (playing && playing.id === id) { btn.textContent = SPEAKER; playing = null } }
}
const bubbleCss = (role) => `padding:8px 11px;border-radius:12px;line-height:1.4;font:13px var(--tac-font,system-ui);white-space:pre-wrap;${role === 'user' ? 'background:#1f6feb;color:#fff;border-bottom-right-radius:4px' : 'background:#1a2230;color:#e8e8e8;border:1px solid #232b36;border-bottom-left-radius:4px'}`
// Horizontal row so the speaker can sit to the RIGHT of an assistant bubble; user bubbles hug right.
const bubble = (role, text) => {
const row = document.createElement('div')
row.style.cssText = `display:flex;align-items:center;gap:6px;max-width:92%;${role === 'user' ? 'align-self:flex-end;flex-direction:row-reverse' : 'align-self:flex-start;flex-direction:row'}`
const b = document.createElement('div'); b.style.cssText = bubbleCss(role); b.textContent = text
row.append(b); row._b = b; return row
}
function renderThread() {
const p = getPersona(personaId)
stopAll()
thread.replaceChildren()
const hist = (p && p.chat && p.chat.history) || []
if (!hist.length) {
const hint = document.createElement('div'); hint.textContent = `Ask ${p ? p.name : 'them'} about a battle, a skill, or how they feel.`
hint.style.cssText = 'color:#6b7686;font-size:12px;text-align:center;margin:auto;padding:20px 6px'; thread.append(hint)
}
for (const m of hist) {
const row = bubble(m.role, m.text)
if (m.role === 'assistant') {
const play = document.createElement('button'); play.type = 'button'; play.textContent = SPEAKER
play.title = 'Hear it'; play.style.cssText = 'flex:none;background:none;border:none;color:#9aa4b2;font-size:18px;line-height:1;cursor:pointer;padding:2px;user-select:none'
play.addEventListener('click', () => speak(m.id, m.text, getPersona(personaId), play))
row.append(play)
}
thread.append(row)
}
thread.scrollTop = thread.scrollHeight
}
function persist(role, id, text) {
const cur = getPersona(personaId); if (!cur) return
const history = [...((cur.chat && cur.chat.history) || []), { id, role, text, ts: Date.now() }].slice(-HISTORY_CAP)
patchPersona(personaId, { chat: { history } })
onUpdate && onUpdate()
}
let busy = false
async function submit() {
const text = input.value.trim(); if (!text || busy) return
const p = getPersona(personaId); if (!p) return
busy = true; send.disabled = true; input.value = ''
persist('user', msgId(), text); renderThread()
// Transient streaming bubble (not yet in history) we write tokens into live.
const row = bubble('assistant', '…'); thread.append(row); thread.scrollTop = thread.scrollHeight
const { system, user } = buildContext(p, text, { live: getLive ? getLive() : null })
let raw = ''
try {
await ensureModel(() => {})
await streamChat(system, user + noThink(currentModelId()), {
maxTokens: 220, temperature: 0.85,
onToken: (t) => { raw += t; row._b.textContent = stripThinkFinal(raw) || '…'; thread.scrollTop = thread.scrollHeight },
})
} catch (e) { row._b.textContent = '…(couldn’t answer: ' + (e && e.message ? e.message : e) + ')' }
const final = stripThinkFinal(raw).trim() || '…'
const id = msgId()
persist('assistant', id, final)
renderThread()
if (getAutoNarrate()) { const btns = thread.querySelectorAll('button'); speak(id, final, getPersona(personaId), btns[btns.length - 1]) }
busy = false; send.disabled = false
}
send.addEventListener('click', submit)
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() } })
renderThread()
return { refresh: renderThread, focus: () => input.focus() }
}