File size: 7,877 Bytes
8eac3eb
9c371b5
 
 
898540a
dffe06d
b862211
 
 
03708ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8eac3eb
03708ca
 
9c371b5
 
 
898540a
 
 
03708ca
 
 
 
8eac3eb
dffe06d
03708ca
898540a
03708ca
 
898540a
 
 
 
 
 
b862211
898540a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03708ca
9c371b5
 
 
 
 
 
 
 
 
 
 
 
717332c
29474fc
9c371b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03708ca
 
8eac3eb
db5fc22
9c371b5
29474fc
03708ca
9c371b5
 
 
 
 
898540a
dffe06d
9c371b5
 
 
 
03708ca
f8d0843
ab87288
f8d0843
898540a
b862211
9c371b5
 
b862211
9c371b5
 
 
8eac3eb
03708ca
62070d0
 
 
 
03708ca
898540a
 
9c371b5
03708ca
898540a
 
 
9c371b5
03708ca
 
 
 
9c371b5
03708ca
9c371b5
03708ca
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
// War-diary panel — vanilla DOM, mounted into #diary-stage. Streams a first-person
// diary entry generated ON THE USER'S DEVICE via the LLM facade, and can READ IT ALOUD
// on the user's device too (Kokoro / Kitten / Web Speech via the TTS facade). Shares
// the persona styling (.persona-*), the model picker, and tok/s stats.
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
import { makeNarrator, ensureTts, setVoiceDescription, getAutoNarrate } from '/web/tts.js'
import { DIARY_SYSTEM, diaryUserPrompt, stripThinkFinal, noThink } from '/web/personaPrompts.js'

const MAX_TOKENS = 100 // short diary entries — cap matches the "~60 words" prompt

function el(tag, props = {}, kids = []) {
  const n = document.createElement(tag)
  for (const [k, v] of Object.entries(props)) {
    if (k === 'class') n.className = v
    else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
    else if (v != null) n.setAttribute(k, v)
  }
  for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
  return n
}

export function mountDiaryPanel(host) {
  const unit = el('input', { class: 'persona-input', type: 'text', value: 'Bram the Warrior' })
  const traits = el('input', { class: 'persona-input', type: 'text', value: 'Cautious, Veteran, Vengeful' })
  const stats = el('div', { class: 'persona-stats' })
  const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
  const btn = el('button', { class: 'persona-go', type: 'button' }, '✒ Write war diary')
  const narrateBtn = el('button', { class: 'persona-go persona-go-alt', type: 'button' }, '🔊 Read aloud')
  const ttsStatus = el('div', { class: 'persona-status tts-status' })
  const out = el('div', { class: 'persona-about' }, 'A first-person diary entry, written by a small model in your browser — and read aloud on your device.')
  const dbgEl = el('pre', { class: 'persona-think' })
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
  const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output / debug (raw)'), copyBtn, dbgEl])

  const controls = el('aside', { class: 'persona-controls' }, [
    el('label', { class: 'persona-label' }, 'Unit'), unit,
    el('label', { class: 'persona-label' }, 'Traits'), traits,
    btn, stats, status,
    narrateBtn, ttsStatus,
  ])
  const result = el('div', { class: 'persona-result' }, [out, dbgWrap])
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))

  let lastDebug = ''
  function buildDebug(outcome, raw) {
    return [
      '=== TINY ARMY · DIARY DEBUG ===',
      `engine:   ${getEngineId()} · ${backendLabel()}`,
      `model:    ${currentModelId()} (${currentModel().label})`,
      `input:    unit=${unit.value} traits=${traits.value} maxTokens=${MAX_TOKENS}`,
      `outcome:  ${outcome}`,
      `--- raw output (${(raw || '').length} chars) ---`,
      raw || '(empty)',
    ].join('\n')
  }
  copyBtn.addEventListener('click', async () => {
    const text = lastDebug || buildDebug('(no generation yet)', '')
    try {
      await navigator.clipboard.writeText(text)
      copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
    } catch {
      dbgEl.textContent = text; dbgWrap.open = true
      const r = document.createRange(); r.selectNodeContents(dbgEl)
      const s = getSelection(); s.removeAllRanges(); s.addRange(r)
      copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
    }
  })

  let busy = false
  let lastBody = '' // diary text (no header), what gets narrated
  let narrator = null
  let speaking = false

  function setSpeaking(on) {
    speaking = on
    narrateBtn.textContent = on ? '⏹ Stop reading' : '🔊 Read aloud'
  }
  function stopNarration() { if (narrator) narrator.stop() }

  // Ensure the TTS model is loaded (showing progress), then return a fresh narrator.
  async function makeReadyNarrator() {
    // For Qwen3-TTS's "persona voice", design a voice from this unit's traits.
    setVoiceDescription(`A war-weary hero's voice — ${(traits.value || 'battle-hardened').trim()}.`)
    ttsStatus.textContent = 'loading voice…'
    await ensureTts((frac) => { ttsStatus.textContent = `downloading voice… ${Math.round(frac * 100)}% (one-time)` })
    ttsStatus.textContent = 'reading on your device…'
    return makeNarrator({
      onState: (s) => {
        if (s === 'done' || s === 'stopped') {
          setSpeaking(false)
          ttsStatus.textContent = s === 'stopped' ? 'stopped' : 'done reading ✓'
        }
      },
    })
  }

  async function readAloud() {
    if (speaking) { stopNarration(); return }
    if (!lastBody.trim()) { ttsStatus.textContent = 'nothing to read yet — write a diary first'; return }
    narrateBtn.disabled = true
    try {
      setSpeaking(true)
      narrator = await makeReadyNarrator()
      narrator.push(lastBody)
      narrator.end()
    } catch (e) {
      setSpeaking(false)
      ttsStatus.textContent = `couldn't read aloud: ${e.message || e}`
    } finally {
      narrateBtn.disabled = false
    }
  }

  async function write() {
    if (busy) return
    busy = true; btn.disabled = true; stats.textContent = ''
    if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
    stopNarration()
    const header = `— Diary of ${(unit.value || 'a nameless hero').trim()} —\n\n`
    out.textContent = header
    lastBody = ''

    // If auto-narrate is on, prepare a live narrator before generation starts.
    let live = null
    let spokenLen = 0
    let raw = ''
    if (getAutoNarrate()) {
      try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
      catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
    }

    try {
      status.textContent = `loading ${currentModel().label} into your browser…`
      await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}${Math.round(frac * 100)}% (one-time)` })
      status.textContent = `writing on your device with ${currentModel().label}…`
      await streamChat(DIARY_SYSTEM, diaryUserPrompt(unit.value, traits.value) + noThink(currentModelId()), {
        maxTokens: MAX_TOKENS, temperature: 0.9,
        onToken: (piece) => {
          raw += piece
          lastBody = stripThinkFinal(raw)
          out.textContent = header + lastBody
          if (live) { const delta = lastBody.slice(spokenLen); if (delta) { live.push(delta); spokenLen = lastBody.length } }
        },
        onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
      })
      // Final pass: keep the answer even if the model left a <think> unclosed (which the
      // live streaming strip would have emptied).
      const finalBody = stripThinkFinal(raw)
      if (finalBody && finalBody !== lastBody) { lastBody = finalBody; out.textContent = header + lastBody }
      status.textContent = 'written ✓ (generated locally)'
      lastDebug = buildDebug(lastBody.trim() ? 'written OK' : 'EMPTY OUTPUT', raw)
      dbgEl.textContent = raw
      if (live) live.end() // flush the tail sentence; onState resets the button
    } catch (e) {
      status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
      lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), raw)
      dbgEl.textContent = raw; dbgWrap.open = true
      if (live) live.stop()
    } finally {
      busy = false; btn.disabled = false
    }
  }

  btn.addEventListener('click', write)
  narrateBtn.addEventListener('click', readAloud)
}