polats Claude Opus 4.8 (1M context) commited on
Commit
dffe06d
·
1 Parent(s): dff270e

Move Voice settings into the Settings page; add a sidebar ⚙ Settings button

Browse files

- settingsPanel.js injects two sections into Gradio's settings page now: "Local AI
Model" + "Voice" (the TTS engine/voice picker, moved off the diary page). Both ride
the shared singletons. Auto-narrate is now global facade state (getAutoNarrate /
setAutoNarrate) so the diary reads it without the bar being mounted.
- diaryPanel: drop the voice bar; keep the Read-aloud button + narration; read
getAutoNarrate() for "narrate as it writes".
- Sidebar gains a "⚙ Settings" item (nav.json) that opens the SAME Gradio settings
page as the footer link (tiny.js wraps tacNavigate to click the footer button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/diaryPanel.js CHANGED
@@ -3,8 +3,7 @@
3
  // on the user's device too (Kokoro / Kitten / Web Speech via the TTS facade). Shares
4
  // the persona styling (.persona-*), the model picker, and tok/s stats.
5
  import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
6
- import { mountTtsBar } from '/web/ttsBar.js'
7
- import { makeNarrator, ensureTts, setVoiceDescription } from '/web/tts.js'
8
  import { DIARY_SYSTEM, diaryUserPrompt, stripThinkFinal, noThink } from '/web/personaPrompts.js'
9
 
10
  const MAX_TOKENS = 100 // short diary entries — cap matches the "~60 words" prompt
@@ -21,7 +20,6 @@ function el(tag, props = {}, kids = []) {
21
  }
22
 
23
  export function mountDiaryPanel(host) {
24
- const ttsHost = el('div')
25
  const unit = el('input', { class: 'persona-input', type: 'text', value: 'Bram the Warrior' })
26
  const traits = el('input', { class: 'persona-input', type: 'text', value: 'Cautious, Veteran, Vengeful' })
27
  const stats = el('div', { class: 'persona-stats' })
@@ -34,22 +32,15 @@ export function mountDiaryPanel(host) {
34
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
35
  const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output / debug (raw)'), copyBtn, dbgEl])
36
 
37
- // On phones the voice bar collapses behind a tap-to-expand summary so the story
38
- // isn't pushed off-screen; on desktop it stays open (summary hidden via CSS).
39
- const ttsWrap = el('details', { class: 'ctl-collapse' }, [el('summary', {}, '🔊 Voice'), ttsHost])
40
- ttsWrap.open = window.innerWidth > 768
41
-
42
  const controls = el('aside', { class: 'persona-controls' }, [
43
  el('label', { class: 'persona-label' }, 'Unit'), unit,
44
  el('label', { class: 'persona-label' }, 'Traits'), traits,
45
  btn, stats, status,
46
- ttsWrap, narrateBtn, ttsStatus,
47
  ])
48
  const result = el('div', { class: 'persona-result' }, [out, dbgWrap])
49
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
50
 
51
- const ttsBar = mountTtsBar(ttsHost)
52
-
53
  let lastDebug = ''
54
  function buildDebug(outcome, raw) {
55
  return [
@@ -133,7 +124,7 @@ export function mountDiaryPanel(host) {
133
  let live = null
134
  let spokenLen = 0
135
  let raw = ''
136
- if (ttsBar.autoNarrate()) {
137
  try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
138
  catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
139
  }
 
3
  // on the user's device too (Kokoro / Kitten / Web Speech via the TTS facade). Shares
4
  // the persona styling (.persona-*), the model picker, and tok/s stats.
5
  import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
6
+ import { makeNarrator, ensureTts, setVoiceDescription, getAutoNarrate } from '/web/tts.js'
 
7
  import { DIARY_SYSTEM, diaryUserPrompt, stripThinkFinal, noThink } from '/web/personaPrompts.js'
8
 
9
  const MAX_TOKENS = 100 // short diary entries — cap matches the "~60 words" prompt
 
20
  }
21
 
22
  export function mountDiaryPanel(host) {
 
23
  const unit = el('input', { class: 'persona-input', type: 'text', value: 'Bram the Warrior' })
24
  const traits = el('input', { class: 'persona-input', type: 'text', value: 'Cautious, Veteran, Vengeful' })
25
  const stats = el('div', { class: 'persona-stats' })
 
32
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
33
  const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output / debug (raw)'), copyBtn, dbgEl])
34
 
 
 
 
 
 
35
  const controls = el('aside', { class: 'persona-controls' }, [
36
  el('label', { class: 'persona-label' }, 'Unit'), unit,
37
  el('label', { class: 'persona-label' }, 'Traits'), traits,
38
  btn, stats, status,
39
+ narrateBtn, ttsStatus,
40
  ])
41
  const result = el('div', { class: 'persona-result' }, [out, dbgWrap])
42
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
43
 
 
 
44
  let lastDebug = ''
45
  function buildDebug(outcome, raw) {
46
  return [
 
124
  let live = null
125
  let spokenLen = 0
126
  let raw = ''
127
+ if (getAutoNarrate()) {
128
  try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
129
  catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
130
  }
web/settingsPanel.js CHANGED
@@ -1,12 +1,13 @@
1
- // Inject the in-browser LLM engine/model picker (and cache management) into Gradio's
2
- // OWN settings page the footer "Settings" link (?view=settings). That page isn't an
3
- // official extension point, so we anchor on its "Display Theme" section, clone its
4
- // styling, and append a matching "Local AI Model" section. The picker drives the shared
5
- // runtime.js singleton, so Personas + War Diaries both use whatever is chosen here.
6
- //
7
- // Fragile by nature (it rides Gradio's internal DOM): if the structure changes the
8
- // section simply won't appear (graceful no-op) and the app still runs on defaults.
9
  import { mountModelBar } from '/web/modelBar.js'
 
10
 
11
  function el(tag, props = {}, kids = []) {
12
  const n = document.createElement(tag)
@@ -19,29 +20,31 @@ function el(tag, props = {}, kids = []) {
19
  return n
20
  }
21
 
22
- function injectInto(sampleSection) {
23
- const list = sampleSection.parentElement
24
- if (!list || list.querySelector('#tac-model-settings')) return
25
- // Clone Gradio's own section + heading classes so it looks native.
26
- const section = el('div', { class: sampleSection.className, id: 'tac-model-settings' })
27
  const h = document.createElement('h2')
28
- h.className = sampleSection.querySelector('h2')?.className || ''
29
- h.textContent = 'Local AI Model'
30
- const intro = el('p', { class: 'tac-set-intro' },
31
- 'The in-browser model that writes your soldiers and their war diaries — shared by ' +
32
- 'Personas and War Diaries. Runs on your device; models cache in your browser.')
33
- const modelHost = el('div')
34
- section.append(h, intro, modelHost)
35
- list.insertBefore(section, sampleSection) // directly above Display Theme (below the title)
36
- mountModelBar(modelHost)
37
  }
38
 
39
  export function mountSettingsPanel() {
40
  const tryInject = () => {
41
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
42
- if (sample) injectInto(sample)
 
 
 
 
 
 
 
43
  }
44
- // The settings modal mounts/unmounts on demand, so watch the DOM and (re)inject.
45
  new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
46
  tryInject()
47
  }
 
1
+ // Inject our settings sections into Gradio's OWN settings page (footer "Settings" or the
2
+ // sidebar button ?view=settings). Not an official extension point, so we anchor on
3
+ // the "Display Theme" section, clone its styling, and prepend matching sections:
4
+ // Local AI Model the in-browser LLM engine/model picker (modelBar)
5
+ // Voice — the read-aloud TTS engine/voice picker (ttsBar)
6
+ // Both drive the shared runtime.js / tts.js singletons, so every page uses the same
7
+ // choice. Fragile by nature (rides Gradio's DOM): if the structure changes the sections
8
+ // just won't appear (graceful no-op) and the app still runs on defaults.
9
  import { mountModelBar } from '/web/modelBar.js'
10
+ import { mountTtsBar } from '/web/ttsBar.js'
11
 
12
  function el(tag, props = {}, kids = []) {
13
  const n = document.createElement(tag)
 
20
  return n
21
  }
22
 
23
+ function injectSection(sample, id, title, intro, mountFn) {
24
+ const list = sample.parentElement
25
+ if (!list || list.querySelector('#' + id)) return
26
+ const section = el('div', { class: sample.className + ' tac-set-section', id })
 
27
  const h = document.createElement('h2')
28
+ h.className = sample.querySelector('h2')?.className || ''
29
+ h.textContent = title
30
+ const host = el('div')
31
+ section.append(h, el('p', { class: 'tac-set-intro' }, intro), host)
32
+ list.insertBefore(section, sample) // above Display Theme, below the title
33
+ mountFn(host)
 
 
 
34
  }
35
 
36
  export function mountSettingsPanel() {
37
  const tryInject = () => {
38
  const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
39
+ if (!sample) return
40
+ // Insert Model first, then Voice (each goes just above Display Theme).
41
+ injectSection(sample, 'tac-model-settings', 'Local AI Model',
42
+ 'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
43
+ 'your device; models cache in your browser.', mountModelBar)
44
+ injectSection(sample, 'tac-voice-settings', 'Voice',
45
+ 'How war diaries are read aloud. Kokoro/Kitten run on your device; Qwen3-TTS ' +
46
+ 'designs a voice in the cloud.', mountTtsBar)
47
  }
 
48
  new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
49
  tryInject()
50
  }
web/shell/nav.json CHANGED
@@ -27,6 +27,12 @@
27
  { "label": "War Diaries", "icon": "📓", "space": "Barracks" },
28
  { "label": "Personas", "icon": "🪖", "space": "Personas" }
29
  ]
 
 
 
 
 
 
30
  }
31
  ]
32
  }
 
27
  { "label": "War Diaries", "icon": "📓", "space": "Barracks" },
28
  { "label": "Personas", "icon": "🪖", "space": "Personas" }
29
  ]
30
+ },
31
+ {
32
+ "title": "App",
33
+ "items": [
34
+ { "label": "Settings", "icon": "⚙", "space": "Settings" }
35
+ ]
36
  }
37
  ]
38
  }
web/shell/persona.css CHANGED
@@ -133,14 +133,14 @@
133
  /* ── "Local AI Model" section injected into Gradio's own Settings page ──────── */
134
  /* The model bar's styles use --p-* vars (normally scoped to .persona-view); define
135
  them here too so the picker renders correctly inside Gradio's settings modal. */
136
- #tac-model-settings {
137
  --p-ink: #141821; --p-muted: #6d6a5f; --p-paper: #f3ebdc; --p-paper-2: #ece2cc;
138
  --p-card: #fbf6ea; --p-transmit: #d8271a;
139
  --p-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
140
  --p-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
141
  }
142
- #tac-model-settings * { box-sizing: border-box; }
143
- #tac-model-settings .model-bar { border-bottom: 0; padding-bottom: 0; }
144
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
145
 
146
  /* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
 
133
  /* ── "Local AI Model" section injected into Gradio's own Settings page ──────── */
134
  /* The model bar's styles use --p-* vars (normally scoped to .persona-view); define
135
  them here too so the picker renders correctly inside Gradio's settings modal. */
136
+ .tac-set-section {
137
  --p-ink: #141821; --p-muted: #6d6a5f; --p-paper: #f3ebdc; --p-paper-2: #ece2cc;
138
  --p-card: #fbf6ea; --p-transmit: #d8271a;
139
  --p-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
140
  --p-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
141
  }
142
+ .tac-set-section * { box-sizing: border-box; }
143
+ .tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
144
  .tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
145
 
146
  /* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
web/tiny.js CHANGED
@@ -58,10 +58,26 @@ whenEl('sprite-stage', async (el) => {
58
  // ── Personas + War Diary tabs — in-browser llama.cpp (wllama), runs on the device ──
59
  whenEl('persona-stage', (el) => { mountPersonaPanel(el) })
60
  whenEl('diary-stage', (el) => { mountDiaryPanel(el) })
61
- // Engine + model picker is injected into Gradio's own Settings page (footer link),
62
- // shared by both Personas and War Diaries via the runtime.js singleton.
63
  mountSettingsPanel()
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
66
  const PLAYERS = [
67
  { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },
 
58
  // ── Personas + War Diary tabs — in-browser llama.cpp (wllama), runs on the device ──
59
  whenEl('persona-stage', (el) => { mountPersonaPanel(el) })
60
  whenEl('diary-stage', (el) => { mountDiaryPanel(el) })
61
+ // Engine + model + voice pickers are injected into Gradio's own Settings page (footer
62
+ // link / sidebar ⚙), shared across pages via the runtime.js + tts.js singletons.
63
  mountSettingsPanel()
64
 
65
+ // Sidebar "⚙ Settings" item opens the SAME Gradio settings page as the footer link.
66
+ // Wrap sidebar.js's tacNavigate (already set, since that's a non-module script): the
67
+ // "Settings" nav target clicks Gradio's footer Settings button; everything else routes
68
+ // to its tab as before.
69
+ const _tacNav = window.tacNavigate
70
+ window.tacNavigate = function (target) {
71
+ if (target === 'Settings') {
72
+ const footer = document.querySelector('footer')
73
+ const btn = footer && Array.prototype.find.call(
74
+ footer.querySelectorAll('button, a'), (e) => /^settings$/i.test((e.textContent || '').trim()))
75
+ if (btn) btn.click()
76
+ return
77
+ }
78
+ if (_tacNav) _tacNav(target)
79
+ }
80
+
81
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
82
  const PLAYERS = [
83
  { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },
web/tts.js CHANGED
@@ -36,6 +36,13 @@ export function setVoice(id) { voiceSel[activeId] = id }
36
 
37
  export const ttsNeedsDownload = () => !!eng().needsDownload
38
  export const ttsBackendLabel = () => eng().backendLabel()
 
 
 
 
 
 
 
39
 
40
  export async function ensureTts(onProgress) {
41
  if (eng().needsDownload) await ensurePersistentStorage()
 
36
 
37
  export const ttsNeedsDownload = () => !!eng().needsDownload
38
  export const ttsBackendLabel = () => eng().backendLabel()
39
+ export const ttsNetworked = () => !!eng().networked
40
+
41
+ // "Narrate as it writes" — global now that the picker lives in Settings (the diary
42
+ // reads it; the settings voice bar sets it).
43
+ let _autoNarrate = false
44
+ export const getAutoNarrate = () => _autoNarrate
45
+ export const setAutoNarrate = (v) => { _autoNarrate = !!v }
46
 
47
  export async function ensureTts(onProgress) {
48
  if (eng().needsDownload) await ensurePersistentStorage()
web/ttsBar.js CHANGED
@@ -6,6 +6,7 @@ import {
6
  listTtsEngines, getTtsEngineId, setTtsEngine,
7
  listVoices, currentVoiceId, setVoice,
8
  ttsBackendLabel, ttsNeedsDownload,
 
9
  } from '/web/tts.js'
10
 
11
  function el(tag, props = {}, kids = []) {
@@ -23,10 +24,10 @@ export function mountTtsBar(host, { onChange } = {}) {
23
  const engSel = el('select', { class: 'model-select engine-select' })
24
  const voiceSel = el('select', { class: 'model-select' })
25
  const auto = el('input', { type: 'checkbox', class: 'tts-auto' })
26
- const autoWrap = el('label', { class: 'tts-auto-row' }, [auto, ' narrate as it writes'])
27
  const info = el('div', { class: 'model-info' })
28
  host.append(el('div', { class: 'model-bar tts-bar' }, [
29
- el('label', { class: 'persona-label' }, '🔊 Voice (reads on your device)'),
30
  engSel,
31
  el('label', { class: 'persona-label' }, 'Voice'),
32
  voiceSel, info, autoWrap,
@@ -50,6 +51,10 @@ export function mountTtsBar(host, { onChange } = {}) {
50
  engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderVoices(); onChange && onChange() })
51
  voiceSel.addEventListener('change', () => { setVoice(voiceSel.value); onChange && onChange() })
52
 
 
 
 
 
53
  // Web Speech voices populate asynchronously.
54
  if (typeof speechSynthesis !== 'undefined') speechSynthesis.onvoiceschanged = () => renderVoices()
55
 
 
6
  listTtsEngines, getTtsEngineId, setTtsEngine,
7
  listVoices, currentVoiceId, setVoice,
8
  ttsBackendLabel, ttsNeedsDownload,
9
+ getAutoNarrate, setAutoNarrate,
10
  } from '/web/tts.js'
11
 
12
  function el(tag, props = {}, kids = []) {
 
24
  const engSel = el('select', { class: 'model-select engine-select' })
25
  const voiceSel = el('select', { class: 'model-select' })
26
  const auto = el('input', { type: 'checkbox', class: 'tts-auto' })
27
+ const autoWrap = el('label', { class: 'tts-auto-row' }, [auto, ' narrate war diaries as they write'])
28
  const info = el('div', { class: 'model-info' })
29
  host.append(el('div', { class: 'model-bar tts-bar' }, [
30
+ el('label', { class: 'persona-label' }, '🔊 Voice (reads war diaries aloud)'),
31
  engSel,
32
  el('label', { class: 'persona-label' }, 'Voice'),
33
  voiceSel, info, autoWrap,
 
51
  engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderVoices(); onChange && onChange() })
52
  voiceSel.addEventListener('change', () => { setVoice(voiceSel.value); onChange && onChange() })
53
 
54
+ // auto-narrate is global state (the diary reads it); reflect + persist it here.
55
+ auto.checked = getAutoNarrate()
56
+ auto.addEventListener('change', () => setAutoNarrate(auto.checked))
57
+
58
  // Web Speech voices populate asynchronously.
59
  if (typeof speechSynthesis !== 'undefined') speechSynthesis.onvoiceschanged = () => renderVoices()
60