Spaces:
Running
Running
Phase 3+4: character chat (woid context) with per-message voice playback
Browse filescharacterContext.js ports woid's two-part pattern client-side: a static identity
system prompt + a dynamic state turn (level, equipped skills, a perception log of
recent kills/level-ups/forged skills) + verbatim recent-chat memory. characterChat.js
mounts a mobile chat thread that streams replies via the local text model
(runtime.js) and persists history to the soul. Each reply gets a ▶ button that speaks
it in the fighter's voice (tts.js), caching the WAV per message in IndexedDB. A
'Talk to <name>' button on the Game character sheet opens it as a bottom-sheet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/characterChat.js +110 -0
- web/characterContext.js +63 -0
- web/tiny.js +24 -0
web/characterChat.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Character chat — talk to a fighter on its character page. Streams replies from the local
|
| 2 |
+
// text model (runtime.js facade) using woid-style assembled context (characterContext.js), so
|
| 3 |
+
// the fighter answers in-voice and aware of its real game state. Each reply gets a ▶ button
|
| 4 |
+
// that speaks it in the fighter's voice (tts.js), caching the WAV per-message (Phase 4).
|
| 5 |
+
import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
|
| 6 |
+
import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
|
| 7 |
+
import { buildContext } from '/web/characterContext.js'
|
| 8 |
+
import { getPersona, patchPersona, putChatAudio, getChatAudio } from '/web/personaStore.js'
|
| 9 |
+
import {
|
| 10 |
+
ensureTts, createVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
|
| 11 |
+
activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
|
| 12 |
+
} from '/web/tts.js'
|
| 13 |
+
|
| 14 |
+
const msgId = () => 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
| 15 |
+
const HISTORY_CAP = 50
|
| 16 |
+
|
| 17 |
+
// Speak `text` in the fighter's voice; cache the WAV under the message id for instant replay.
|
| 18 |
+
async function speak(id, text, p, btn) {
|
| 19 |
+
const set = (s) => { if (btn) btn.textContent = s }
|
| 20 |
+
try {
|
| 21 |
+
if (activeEngineIsNative()) { set('⏳'); await ensureTts(); await speakVoiceLive(p.voiceId || currentVoiceId(), text); return }
|
| 22 |
+
let blob = await getChatAudio(id)
|
| 23 |
+
let bytes
|
| 24 |
+
if (blob) { bytes = await blob.arrayBuffer() }
|
| 25 |
+
else {
|
| 26 |
+
set('⏳'); await ensureTts()
|
| 27 |
+
const wav = activeEngineIsDesign() ? await createVoiceWav(p.voice || '', text) : await synthVoiceWav(p.voiceId || currentVoiceId(), text)
|
| 28 |
+
bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
|
| 29 |
+
try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
|
| 30 |
+
}
|
| 31 |
+
set('⏸'); await playWav(bytes.slice(0))
|
| 32 |
+
} catch { /* voice is best-effort */ } finally { set('▶') }
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function mountCharacterChat(host, personaId, { onUpdate } = {}) {
|
| 36 |
+
const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
|
| 37 |
+
const thread = document.createElement('div'); thread.style.cssText = 'flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:4px 2px 8px'
|
| 38 |
+
const form = document.createElement('div'); form.style.cssText = 'display:flex;gap:8px;align-items:flex-end;border-top:1px solid #232b36;padding-top:8px'
|
| 39 |
+
const input = document.createElement('textarea'); input.rows = 1; input.placeholder = 'say something…'
|
| 40 |
+
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'
|
| 41 |
+
const send = document.createElement('button'); send.type = 'button'; send.textContent = '➤'
|
| 42 |
+
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'
|
| 43 |
+
form.append(input, send); wrap.append(thread, form); host.append(wrap)
|
| 44 |
+
|
| 45 |
+
const bubble = (role, text) => {
|
| 46 |
+
const row = document.createElement('div'); row.style.cssText = `display:flex;flex-direction:column;max-width:88%;${role === 'user' ? 'align-self:flex-end;align-items:flex-end' : 'align-self:flex-start;align-items:flex-start'}`
|
| 47 |
+
const b = document.createElement('div')
|
| 48 |
+
b.style.cssText = `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'}`
|
| 49 |
+
b.textContent = text
|
| 50 |
+
row.append(b); row._b = b; return row
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function renderThread() {
|
| 54 |
+
const p = getPersona(personaId)
|
| 55 |
+
thread.replaceChildren()
|
| 56 |
+
const hist = (p && p.chat && p.chat.history) || []
|
| 57 |
+
if (!hist.length) {
|
| 58 |
+
const hint = document.createElement('div'); hint.textContent = `Ask ${p ? p.name : 'them'} about a battle, a skill, or how they feel.`
|
| 59 |
+
hint.style.cssText = 'color:#6b7686;font-size:12px;text-align:center;margin:auto;padding:20px 6px'; thread.append(hint)
|
| 60 |
+
}
|
| 61 |
+
for (const m of hist) {
|
| 62 |
+
const row = bubble(m.role, m.text)
|
| 63 |
+
if (m.role === 'assistant') {
|
| 64 |
+
const play = document.createElement('button'); play.type = 'button'; play.textContent = '▶'
|
| 65 |
+
play.title = 'Hear it'; play.style.cssText = 'margin-top:3px;background:none;border:none;color:#9aa4b2;font-size:12px;cursor:pointer;padding:2px 4px'
|
| 66 |
+
play.addEventListener('click', () => speak(m.id, m.text, getPersona(personaId), play))
|
| 67 |
+
row.append(play)
|
| 68 |
+
}
|
| 69 |
+
thread.append(row)
|
| 70 |
+
}
|
| 71 |
+
thread.scrollTop = thread.scrollHeight
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function persist(role, id, text) {
|
| 75 |
+
const cur = getPersona(personaId); if (!cur) return
|
| 76 |
+
const history = [...((cur.chat && cur.chat.history) || []), { id, role, text, ts: Date.now() }].slice(-HISTORY_CAP)
|
| 77 |
+
patchPersona(personaId, { chat: { history } })
|
| 78 |
+
onUpdate && onUpdate()
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
let busy = false
|
| 82 |
+
async function submit() {
|
| 83 |
+
const text = input.value.trim(); if (!text || busy) return
|
| 84 |
+
const p = getPersona(personaId); if (!p) return
|
| 85 |
+
busy = true; send.disabled = true; input.value = ''
|
| 86 |
+
persist('user', msgId(), text); renderThread()
|
| 87 |
+
// Transient streaming bubble (not yet in history) we write tokens into live.
|
| 88 |
+
const row = bubble('assistant', '…'); thread.append(row); thread.scrollTop = thread.scrollHeight
|
| 89 |
+
const { system, user } = buildContext(p, text)
|
| 90 |
+
let raw = ''
|
| 91 |
+
try {
|
| 92 |
+
await ensureModel(() => {})
|
| 93 |
+
await streamChat(system, user + noThink(currentModelId()), {
|
| 94 |
+
maxTokens: 220, temperature: 0.85,
|
| 95 |
+
onToken: (t) => { raw += t; row._b.textContent = stripThinkFinal(raw) || '…'; thread.scrollTop = thread.scrollHeight },
|
| 96 |
+
})
|
| 97 |
+
} catch (e) { row._b.textContent = '…(couldn’t answer: ' + (e && e.message ? e.message : e) + ')' }
|
| 98 |
+
const final = stripThinkFinal(raw).trim() || '…'
|
| 99 |
+
const id = msgId()
|
| 100 |
+
persist('assistant', id, final)
|
| 101 |
+
renderThread()
|
| 102 |
+
if (getAutoNarrate()) { const btns = thread.querySelectorAll('button'); speak(id, final, getPersona(personaId), btns[btns.length - 1]) }
|
| 103 |
+
busy = false; send.disabled = false
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
send.addEventListener('click', submit)
|
| 107 |
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() } })
|
| 108 |
+
renderThread()
|
| 109 |
+
return { refresh: renderThread, focus: () => input.focus() }
|
| 110 |
+
}
|
web/characterContext.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Character context — a single-player, client-side port of woid's two-part context pattern
|
| 2 |
+
// (agent-sandbox/pi-bridge/buildContext.js): a STATIC system prompt that is the fighter's
|
| 3 |
+
// identity, plus a DYNAMIC "what's happening to you now" turn assembled from live game state
|
| 4 |
+
// (level, recent battle/skill events — woid's perception log) and a VERBATIM slice of the
|
| 5 |
+
// recent conversation (woid drops summarization; so do we). See docs/rpg-progression-plan.md.
|
| 6 |
+
|
| 7 |
+
import { effectSummary } from '/web/skillSchema.js'
|
| 8 |
+
|
| 9 |
+
// One perception-log line per event, in the fighter's lived-experience voice.
|
| 10 |
+
function formatEvent(e) {
|
| 11 |
+
switch (e && e.type) {
|
| 12 |
+
case 'kill': return `You defeated ${e.enemy || 'a foe'} (+${e.xp || 0} xp).`
|
| 13 |
+
case 'level_up': return `You grew stronger — you reached level ${e.level}.`
|
| 14 |
+
case 'skill_learned': return `You forged a new skill: "${e.skill}".`
|
| 15 |
+
default: return null
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// STATIC identity. Built once per chat session; the fighter's "bible".
|
| 20 |
+
export function buildSystemPrompt(p) {
|
| 21 |
+
if (!p) return 'You are a fighter in a fantasy auto-battler. Stay in character.'
|
| 22 |
+
const lines = [
|
| 23 |
+
`You are ${p.name || 'a nameless fighter'}${p.unitClass ? `, a ${p.unitClass}` : ''} in a fantasy auto-battler called Tiny Army.`,
|
| 24 |
+
p.about ? `Who you are: ${p.about}` : '',
|
| 25 |
+
[p.personality && `Personality: ${p.personality}`, p.vibe && `Vibe: ${p.vibe}`, p.specialty && `Specialty: ${p.specialty}`].filter(Boolean).join('. '),
|
| 26 |
+
'',
|
| 27 |
+
'You are a REAL fighter who has actually lived the battles described below — speak from that experience, in first person, never as an assistant.',
|
| 28 |
+
'Stay fully in character. Keep replies short and natural (1-3 sentences). Never mention being an AI, a model, or a game system. Do not use markdown or stage directions.',
|
| 29 |
+
]
|
| 30 |
+
return lines.filter((l) => l !== '').join('\n')
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// DYNAMIC turn: current standing + recent perception events + verbatim recent chat + new line.
|
| 34 |
+
export function buildStateTurn(p, userMsg, { maxEvents = 5, maxHistory = 8 } = {}) {
|
| 35 |
+
const prog = (p && p.progression) || { level: 1, kills: 0 }
|
| 36 |
+
const cs = (p && p.customSkills) || {}
|
| 37 |
+
const equipped = (p && p.equippedSkills || []).map((id) => cs[id]).filter(Boolean)
|
| 38 |
+
const events = (p && p.events || []).map(formatEvent).filter(Boolean).slice(-maxEvents)
|
| 39 |
+
const history = (p && p.chat && p.chat.history || []).slice(-maxHistory)
|
| 40 |
+
|
| 41 |
+
const state = [
|
| 42 |
+
`Level ${prog.level}${prog.kills ? `, ${prog.kills} foes felled` : ''}.`,
|
| 43 |
+
equipped.length ? `Skills at the ready: ${equipped.map((s) => `${s.name} (${effectSummary(s)})`).join('; ')}.` : '',
|
| 44 |
+
events.length ? `Lately:\n${events.map((e) => ` - ${e}`).join('\n')}` : '',
|
| 45 |
+
].filter(Boolean).join('\n')
|
| 46 |
+
|
| 47 |
+
const convo = history.map((m) => `${m.role === 'user' ? 'Player' : 'You'}: ${m.text}`).join('\n')
|
| 48 |
+
|
| 49 |
+
return [
|
| 50 |
+
'[Where you stand right now]',
|
| 51 |
+
state || 'You are fresh to the field, untested.',
|
| 52 |
+
'',
|
| 53 |
+
convo ? '[Your conversation so far]\n' + convo + '\n' : '',
|
| 54 |
+
`Player: ${userMsg}`,
|
| 55 |
+
'',
|
| 56 |
+
`Reply as ${(p && p.name) || 'yourself'}, in first person, 1-3 sentences.`,
|
| 57 |
+
].filter((l) => l !== '').join('\n')
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Convenience: assemble both halves for a turn.
|
| 61 |
+
export function buildContext(p, userMsg, opts) {
|
| 62 |
+
return { system: buildSystemPrompt(p), user: buildStateTurn(p, userMsg, opts) }
|
| 63 |
+
}
|
web/tiny.js
CHANGED
|
@@ -20,6 +20,7 @@ import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent } from '/we
|
|
| 20 |
import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
| 22 |
import { generatePortrait } from '/web/imagen.js'
|
|
|
|
| 23 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 24 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 25 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
@@ -451,6 +452,23 @@ whenEl('battle-stage', async (el) => {
|
|
| 451 |
} catch { art.textContent = 'art unavailable' }
|
| 452 |
})
|
| 453 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
const renderSheet = () => {
|
| 455 |
sheet.innerHTML = ''
|
| 456 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
@@ -478,6 +496,12 @@ whenEl('battle-stage', async (el) => {
|
|
| 478 |
if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
|
| 479 |
sheet.append(meta)
|
| 480 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
if (hero) {
|
| 482 |
const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
|
| 483 |
const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'
|
|
|
|
| 20 |
import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
| 22 |
import { generatePortrait } from '/web/imagen.js'
|
| 23 |
+
import { mountCharacterChat } from '/web/characterChat.js'
|
| 24 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 25 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 26 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
|
|
| 452 |
} catch { art.textContent = 'art unavailable' }
|
| 453 |
})
|
| 454 |
}
|
| 455 |
+
// Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
|
| 456 |
+
let chatSheet = null
|
| 457 |
+
const openChat = () => {
|
| 458 |
+
if (!currentHero || !currentHero.id) return
|
| 459 |
+
chatSheet?.remove()
|
| 460 |
+
const back = document.createElement('div'); back.style.cssText = 'position:absolute;inset:0;z-index:11;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'
|
| 461 |
+
const sh = document.createElement('div'); sh.style.cssText = 'width:min(440px,96vw);height:82%;display:flex;flex-direction:column;background:#11151c;border:1px solid #2a3340;border-radius:16px 16px 0 0;padding:14px;color:#e8e8e8;box-sizing:border-box'
|
| 462 |
+
const head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:8px'
|
| 463 |
+
const ttl = document.createElement('div'); ttl.textContent = `💬 ${currentHero.name || 'Hero'}`; ttl.style.cssText = 'font:700 16px var(--tac-font,system-ui)'
|
| 464 |
+
const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'background:none;border:none;color:#9aa4b2;font-size:20px;cursor:pointer'
|
| 465 |
+
const closeChat = () => { back.remove(); chatSheet = null }
|
| 466 |
+
x.addEventListener('click', closeChat); head.append(ttl, x); sh.append(head)
|
| 467 |
+
const body = document.createElement('div'); body.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'; sh.append(body)
|
| 468 |
+
const chat = mountCharacterChat(body, currentHero.id, {}); setTimeout(() => chat.focus(), 50)
|
| 469 |
+
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeChat() })
|
| 470 |
+
back.append(sh); el.append(back); chatSheet = back
|
| 471 |
+
}
|
| 472 |
const renderSheet = () => {
|
| 473 |
sheet.innerHTML = ''
|
| 474 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
|
|
| 496 |
if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
|
| 497 |
sheet.append(meta)
|
| 498 |
}
|
| 499 |
+
// Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
|
| 500 |
+
if (currentHero.id) {
|
| 501 |
+
const talk = document.createElement('button'); talk.type = 'button'; talk.textContent = `💬 Talk to ${currentHero.name || 'them'}`
|
| 502 |
+
talk.style.cssText = 'width:100%;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#16202e;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin-bottom:12px'
|
| 503 |
+
talk.addEventListener('click', openChat); sheet.append(talk)
|
| 504 |
+
}
|
| 505 |
if (hero) {
|
| 506 |
const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
|
| 507 |
const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'
|