Spaces:
Running
Running
| // 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() } | |
| } | |