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() }
}