File size: 7,628 Bytes
dffe06d
 
 
f9dd2fe
dffe06d
 
 
 
627d835
dffe06d
2151ea1
575fb61
750ca83
1f1908e
c90f2a6
627d835
 
 
 
 
 
 
 
 
 
 
 
dffe06d
 
 
 
cd43499
dffe06d
 
 
 
 
 
627d835
cd43499
46e3cc5
 
 
 
 
 
 
 
620430e
 
 
 
 
 
 
 
46e3cc5
 
c90f2a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd43499
 
 
dffe06d
750ca83
 
 
 
 
f9dd2fe
 
 
dffe06d
e648dca
 
 
2151ea1
 
 
575fb61
 
 
1f1908e
 
 
353687b
 
c90f2a6
 
 
46e3cc5
 
 
cd43499
 
 
 
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
// Inject our settings sections into Gradio's OWN settings page (footer "Settings" or the
// sidebar ⚙ button → ?view=settings). Not an official extension point, so we anchor on
// the "Display Theme" section, clone its styling, and prepend matching sections:
//   • Text Generation Model — the LLM engine/model picker (modelBar)
//   • Voice          — the read-aloud TTS engine/voice picker (ttsBar)
// Both drive the shared runtime.js / tts.js singletons, so every page uses the same
// choice. Fragile by nature (rides Gradio's DOM): if the structure changes the sections
// just won't appear (graceful no-op) and the app still runs on defaults.
import { mountModelBar } from '/web/modelBar.js'
import { mountTtsBar } from '/web/ttsBar.js'
import { mountImagenBar } from '/web/imagenBar.js'
import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
import { mountQualityBar } from '/web/qualityBar.js'
import { mountCodingModelBar } from '/web/codingModelBar.js'
import { recentCalls, onDiag } from '/web/diag.js'

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
}

function injectSection(sample, id, title, intro, mountFn) {
  const list = sample.parentElement
  if (!list || list.querySelector('#' + id)) return
  const section = el('div', { class: sample.className + ' tac-set-section', id })
  const h = document.createElement('h2')
  h.className = sample.querySelector('h2')?.className || ''
  h.textContent = title
  const host = el('div')
  section.append(h, el('p', { class: 'tac-set-intro' }, intro), host)
  list.insertBefore(section, sample) // above Display Theme, below the title
  mountFn(host)
}

// Gameplay toggles. Currently just emotes on/off, persisted to the same localStorage flag the
// emote layer reads ('tinyarmy.emotes', default on → only '0' disables).
function mountGameplayBar(host) {
  const cb = el('input', { type: 'checkbox' })
  try { cb.checked = localStorage.getItem('tinyarmy.emotes') !== '0' } catch { cb.checked = true }
  cb.addEventListener('change', () => { try { localStorage.setItem('tinyarmy.emotes', cb.checked ? '1' : '0') } catch { /* ignore */ } })
  const lab = el('label', { class: 'tac-set-row', style: 'display:flex;align-items:center;gap:8px;cursor:pointer' }, [cb, 'Show character emotes'])
  host.append(lab)
  // Replay the first-run tour. tiny.js exposes the reset; reset the flag too so it re-arms even if
  // the Game tab isn't mounted yet (it'll auto-start next time you open it).
  const replay = el('button', { type: 'button', style: 'margin-top:10px;padding:9px 14px;border-radius:8px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer',
    onclick: () => {
      if (typeof window.tinyArmyReplayTutorial === 'function') { window.tinyArmyReplayTutorial(); replay.textContent = 'Tour restarted — open the Game tab' }
      else { try { localStorage.removeItem('tinyarmy.tutorial.v1') } catch { /* ignore */ } replay.textContent = 'Tour re-armed — open the Game tab' }
    } }, 'Replay tutorial')
  host.append(replay)
}

// Diagnostics: a live readout of recent model/media calls (image, voice, skill, text) with
// status + timing — for troubleshooting "stuck loading" / silent failures without devtools.
function mountDiagnosticsBar(host) {
  const fmt = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms || 0) + 'ms')
  const list = el('div', { style: 'font:11px ui-monospace,monospace;max-height:240px;overflow:auto;border:1px solid #2a3340;border-radius:8px;padding:8px;background:rgba(20,24,33,.5)' })
  const render = () => {
    const calls = recentCalls().slice(-25).reverse()
    list.replaceChildren()
    if (!calls.length) { list.append(el('div', { style: 'color:#9aa4b2' }, 'No model/media calls yet — generate a portrait, voice or skill.')); return }
    for (const c of calls) {
      const mark = c.ok ? '✓' : (c.timedOut ? '⏱ TIMEOUT' : '✗')
      const detail = c.label || c.error || (c.status ? 'HTTP ' + c.status : '') || c.url || ''
      const row = el('div', { style: `padding:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${c.ok ? '#8fe388' : '#ff8a8a'}` },
        `${new Date(c.ts).toLocaleTimeString()}  ${c.kind}  ${mark}  ${fmt(c.ms)}  ${detail}`)
      list.append(row)
    }
  }
  const hint = el('p', { class: 'tac-set-intro', style: 'margin:0 0 6px' }, 'Tip: also run tinyDiag() in the browser console for the full table.')
  host.append(hint, list)
  render(); onDiag(render)
}

export function mountSettingsPanel() {
  const tryInject = () => {
    const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
    if (!sample) return
    // Recommended (preset) first so it sits at the very top, then Model, Voice, etc.
    // Each injectSection inserts just above Display Theme, so call order = on-screen order.
    injectSection(sample, 'tac-quality-settings', 'Recommended settings',
      'Pick a quality preset — it sets the AI model and voice together, like graphics ' +
      'presets in a game. Changing either by hand switches to Custom.', mountQualityBar)
    injectSection(sample, 'tac-model-settings', 'Text Generation Model',
      'The model that writes your soldiers and their war diaries. Use browser-local ' +
      'models, a configured local server, or a ZeroGPU-hosted model.', mountModelBar)
    injectSection(sample, 'tac-voice-settings', 'Voice',
      'The provider that voices your heroes. Qwen3-TTS designs a voice from each hero’s ' +
      'description; Kokoro/Kitten run on your device with a named voice you pick per hero. ' +
      'The voice belongs to the hero, so there’s no global voice to choose here.', mountTtsBar)
    injectSection(sample, 'tac-image-settings', 'Portrait',
      'The model that paints hero portraits. Z-Image-Turbo runs on your GPU when you host ' +
      'the project locally; FLUX runs in the cloud otherwise.', mountImagenBar)
    injectSection(sample, 'tac-persona-prompt-settings', 'Persona Prompt',
      'The system prompt that writes each hero (name, about, quote and voice design). ' +
      'Edit it to change their style; Save uses it on the next “Recruit hero”.', mountPersonaPromptBar)
    injectSection(sample, 'tac-coding-model-settings', 'Coding Model',
      'The model that powers the Skill Forge — it writes a skill for a chosen hero. ' +
      'Nemotron 3 Nano (NVIDIA) runs via NVIDIA NIM; Mellum2 (JetBrains) runs as a ' +
      'ZeroGPU sidecar and falls back to Nemotron (NIM) if its sidecar is unavailable.',
      mountCodingModelBar)
    injectSection(sample, 'tac-diagnostics-settings', 'Diagnostics',
      'Recent model/media calls (image, voice, skill, text) with status + timing. Use this to ' +
      'troubleshoot generation that is stuck loading or failing — timeouts now surface as ⏱.', mountDiagnosticsBar)
    injectSection(sample, 'tac-gameplay-settings', 'Gameplay',
      'On-map flourishes during the Game. Emotes pop little reaction bubbles above heroes ' +
      'and enemies when they take a hit, get afflicted, drop low, or score a kill.', mountGameplayBar)
  }
  new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
  tryInject()
}