Spaces:
Running
Running
File size: 8,343 Bytes
72082c8 68fbbfb 72082c8 68fbbfb 72082c8 a1a7733 72082c8 f1fdeff 72082c8 71a4292 72082c8 a1a7733 f1fdeff a1a7733 f1fdeff a1a7733 68fbbfb a1a7733 f1fdeff a1a7733 72082c8 a1a7733 72082c8 a1a7733 72082c8 a1a7733 72082c8 71a4292 72082c8 | 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 | // 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() }
}
|