Spaces:
Running
Running
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 +3 -12
- web/settingsPanel.js +27 -24
- web/shell/nav.json +6 -0
- web/shell/persona.css +3 -3
- web/tiny.js +18 -2
- web/tts.js +7 -0
- web/ttsBar.js +7 -2
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 {
|
| 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 |
-
|
| 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 (
|
| 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
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
| 7 |
-
// Fragile by nature (
|
| 8 |
-
//
|
| 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
|
| 23 |
-
const list =
|
| 24 |
-
if (!list || list.querySelector('#
|
| 25 |
-
|
| 26 |
-
const section = el('div', { class: sampleSection.className, id: 'tac-model-settings' })
|
| 27 |
const h = document.createElement('h2')
|
| 28 |
-
h.className =
|
| 29 |
-
h.textContent =
|
| 30 |
-
const
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 143 |
-
|
| 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
|
| 62 |
-
//
|
| 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
|
| 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
|
| 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 |
|