Spaces:
Running
Running
Move engine/model settings to a Settings page; share one model across both pages
Browse filesThe model picker was duplicated on Personas and War Diaries. Move the engine +
model selector and cache controls into a new Settings tab (settingsPanel.js); both
pages now read the same selection from the runtime.js singleton, so there's one
place to choose the model and it's guaranteed identical on both. The diary page
keeps its Voice (TTS) controls. nav.json gains an "App › Settings" item (Space-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +7 -3
- web/diaryPanel.js +3 -9
- web/personaPanel.js +0 -11
- web/settingsPanel.js +34 -0
- web/shell/nav.json +6 -0
- web/shell/persona.css +8 -0
- web/tiny.js +3 -0
app.py
CHANGED
|
@@ -91,11 +91,11 @@ THEME = ('<style>'
|
|
| 91 |
# Gradio still hides it (display:none on the inactive tab's ancestor).
|
| 92 |
'.gradio-container .tabitem{padding:0 !important;}'
|
| 93 |
'.gradio-container .tabs{border:0 !important;}'
|
| 94 |
-
'#sprite-stage,#persona-stage,#diary-stage{position:fixed !important;top:0;bottom:0;'
|
| 95 |
'right:0;left:var(--tac-w,240px);height:auto !important;z-index:1;}'
|
| 96 |
'body.tac-collapsed #sprite-stage,body.tac-collapsed #persona-stage,'
|
| 97 |
-
'body.tac-collapsed #diary-stage{left:0;}'
|
| 98 |
-
'@media (max-width:768px){#sprite-stage,#persona-stage,#diary-stage{left:0;}}'
|
| 99 |
'</style>')
|
| 100 |
HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
|
| 101 |
+ HIDE_TABS + FONTS + THEME +
|
|
@@ -194,6 +194,10 @@ with gr.Blocks(title="Tiny Army") as demo:
|
|
| 194 |
with gr.Tab("Personas"):
|
| 195 |
# In-browser persona generator (web/personaPanel.js → wllama).
|
| 196 |
gr.HTML('<div id="persona-stage" style="overflow:hidden"></div>')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
# Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
|
| 199 |
fastapi_app = FastAPI()
|
|
|
|
| 91 |
# Gradio still hides it (display:none on the inactive tab's ancestor).
|
| 92 |
'.gradio-container .tabitem{padding:0 !important;}'
|
| 93 |
'.gradio-container .tabs{border:0 !important;}'
|
| 94 |
+
'#sprite-stage,#persona-stage,#diary-stage,#settings-stage{position:fixed !important;top:0;bottom:0;'
|
| 95 |
'right:0;left:var(--tac-w,240px);height:auto !important;z-index:1;}'
|
| 96 |
'body.tac-collapsed #sprite-stage,body.tac-collapsed #persona-stage,'
|
| 97 |
+
'body.tac-collapsed #diary-stage,body.tac-collapsed #settings-stage{left:0;}'
|
| 98 |
+
'@media (max-width:768px){#sprite-stage,#persona-stage,#diary-stage,#settings-stage{left:0;}}'
|
| 99 |
'</style>')
|
| 100 |
HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
|
| 101 |
+ HIDE_TABS + FONTS + THEME +
|
|
|
|
| 194 |
with gr.Tab("Personas"):
|
| 195 |
# In-browser persona generator (web/personaPanel.js → wllama).
|
| 196 |
gr.HTML('<div id="persona-stage" style="overflow:hidden"></div>')
|
| 197 |
+
with gr.Tab("Settings"):
|
| 198 |
+
# Engine + model picker + cache management (web/settingsPanel.js). Shared by
|
| 199 |
+
# the Personas and War Diaries pages via the runtime.js singleton.
|
| 200 |
+
gr.HTML('<div id="settings-stage" style="overflow:hidden"></div>')
|
| 201 |
|
| 202 |
# Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
|
| 203 |
fastapi_app = FastAPI()
|
web/diaryPanel.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
| 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 } from '/web/runtime.js'
|
| 6 |
-
import { mountModelBar } from '/web/modelBar.js'
|
| 7 |
import { mountTtsBar } from '/web/ttsBar.js'
|
| 8 |
import { makeNarrator, ensureTts } from '/web/tts.js'
|
| 9 |
import { DIARY_SYSTEM, diaryUserPrompt, stripThink, noThink, thinkMaxTokens } from '/web/personaPrompts.js'
|
|
@@ -20,7 +19,6 @@ function el(tag, props = {}, kids = []) {
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export function mountDiaryPanel(host) {
|
| 23 |
-
const modelHost = el('div')
|
| 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' })
|
|
@@ -31,14 +29,12 @@ export function mountDiaryPanel(host) {
|
|
| 31 |
const ttsStatus = el('div', { class: 'persona-status tts-status' })
|
| 32 |
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.')
|
| 33 |
|
| 34 |
-
// On phones the
|
| 35 |
-
//
|
| 36 |
-
const modelWrap = el('details', { class: 'ctl-collapse' }, [el('summary', {}, '⚙ Model'), modelHost])
|
| 37 |
const ttsWrap = el('details', { class: 'ctl-collapse' }, [el('summary', {}, '🔊 Voice'), ttsHost])
|
| 38 |
-
|
| 39 |
|
| 40 |
const controls = el('aside', { class: 'persona-controls' }, [
|
| 41 |
-
modelWrap,
|
| 42 |
el('label', { class: 'persona-label' }, 'Unit'), unit,
|
| 43 |
el('label', { class: 'persona-label' }, 'Traits'), traits,
|
| 44 |
btn, stats, status,
|
|
@@ -47,7 +43,6 @@ export function mountDiaryPanel(host) {
|
|
| 47 |
const result = el('div', { class: 'persona-result' }, [out])
|
| 48 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 49 |
|
| 50 |
-
const bar = mountModelBar(modelHost)
|
| 51 |
const ttsBar = mountTtsBar(ttsHost)
|
| 52 |
|
| 53 |
let busy = false
|
|
@@ -127,7 +122,6 @@ export function mountDiaryPanel(host) {
|
|
| 127 |
})
|
| 128 |
status.textContent = 'written ✓ (generated locally)'
|
| 129 |
if (live) live.end() // flush the tail sentence; onState resets the button
|
| 130 |
-
bar.refresh()
|
| 131 |
} catch (e) {
|
| 132 |
status.textContent = `couldn't run the local model: ${e.message || e}`
|
| 133 |
if (live) live.stop()
|
|
|
|
| 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 } from '/web/runtime.js'
|
|
|
|
| 6 |
import { mountTtsBar } from '/web/ttsBar.js'
|
| 7 |
import { makeNarrator, ensureTts } from '/web/tts.js'
|
| 8 |
import { DIARY_SYSTEM, diaryUserPrompt, stripThink, noThink, thinkMaxTokens } from '/web/personaPrompts.js'
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
export function mountDiaryPanel(host) {
|
|
|
|
| 22 |
const ttsHost = el('div')
|
| 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' })
|
|
|
|
| 29 |
const ttsStatus = el('div', { class: 'persona-status tts-status' })
|
| 30 |
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.')
|
| 31 |
|
| 32 |
+
// On phones the voice bar collapses behind a tap-to-expand summary so the story
|
| 33 |
+
// isn't pushed off-screen; on desktop it stays open (summary hidden via CSS).
|
|
|
|
| 34 |
const ttsWrap = el('details', { class: 'ctl-collapse' }, [el('summary', {}, '🔊 Voice'), ttsHost])
|
| 35 |
+
ttsWrap.open = window.innerWidth > 768
|
| 36 |
|
| 37 |
const controls = el('aside', { class: 'persona-controls' }, [
|
|
|
|
| 38 |
el('label', { class: 'persona-label' }, 'Unit'), unit,
|
| 39 |
el('label', { class: 'persona-label' }, 'Traits'), traits,
|
| 40 |
btn, stats, status,
|
|
|
|
| 43 |
const result = el('div', { class: 'persona-result' }, [out])
|
| 44 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 45 |
|
|
|
|
| 46 |
const ttsBar = mountTtsBar(ttsHost)
|
| 47 |
|
| 48 |
let busy = false
|
|
|
|
| 122 |
})
|
| 123 |
status.textContent = 'written ✓ (generated locally)'
|
| 124 |
if (live) live.end() // flush the tail sentence; onState resets the button
|
|
|
|
| 125 |
} catch (e) {
|
| 126 |
status.textContent = `couldn't run the local model: ${e.message || e}`
|
| 127 |
if (live) live.stop()
|
web/personaPanel.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
| 3 |
// (modelBar), generation streams into a live "thinking" view + parsed result, and we
|
| 4 |
// show tok/s. Reuses woid's persona parser + extractLivePersona verbatim.
|
| 5 |
import { streamChat, ensureModel, currentModel } from '/web/runtime.js'
|
| 6 |
-
import { mountModelBar } from '/web/modelBar.js'
|
| 7 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 8 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 9 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, noThink, thinkMaxTokens } from '/web/personaPrompts.js'
|
|
@@ -22,7 +21,6 @@ function el(tag, props = {}, kids = []) {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
export function mountPersonaPanel(host) {
|
| 25 |
-
const modelHost = el('div')
|
| 26 |
const sel = el('select', { class: 'persona-input' }, CLASSES.map((c) => el('option', { value: c }, c)))
|
| 27 |
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 28 |
const stats = el('div', { class: 'persona-stats' })
|
|
@@ -35,13 +33,7 @@ export function mountPersonaPanel(host) {
|
|
| 35 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 36 |
const thinkWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output (raw)'), thinkEl])
|
| 37 |
|
| 38 |
-
// On phones the model bar collapses behind a tap-to-expand summary so the result
|
| 39 |
-
// isn't pushed off-screen; on desktop it stays open (summary hidden via CSS).
|
| 40 |
-
const modelWrap = el('details', { class: 'ctl-collapse' }, [el('summary', {}, '⚙ Model'), modelHost])
|
| 41 |
-
modelWrap.open = window.innerWidth > 768
|
| 42 |
-
|
| 43 |
const controls = el('aside', { class: 'persona-controls' }, [
|
| 44 |
-
modelWrap,
|
| 45 |
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 46 |
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 47 |
btn, stats, status,
|
|
@@ -49,8 +41,6 @@ export function mountPersonaPanel(host) {
|
|
| 49 |
const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, aboutEl, thinkWrap])
|
| 50 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 51 |
|
| 52 |
-
const bar = mountModelBar(modelHost)
|
| 53 |
-
|
| 54 |
function setTags(p) {
|
| 55 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 56 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
|
@@ -93,7 +83,6 @@ export function mountPersonaPanel(host) {
|
|
| 93 |
} catch (e) {
|
| 94 |
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e})`
|
| 95 |
}
|
| 96 |
-
bar.refresh() // it's now cached
|
| 97 |
} catch (e) {
|
| 98 |
status.textContent = `couldn't run the local model: ${e.message || e}`
|
| 99 |
} finally {
|
|
|
|
| 3 |
// (modelBar), generation streams into a live "thinking" view + parsed result, and we
|
| 4 |
// show tok/s. Reuses woid's persona parser + extractLivePersona verbatim.
|
| 5 |
import { streamChat, ensureModel, currentModel } from '/web/runtime.js'
|
|
|
|
| 6 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 7 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 8 |
import { PERSONA_SYSTEM, personaUserPrompt, stripThink, noThink, thinkMaxTokens } from '/web/personaPrompts.js'
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
export function mountPersonaPanel(host) {
|
|
|
|
| 24 |
const sel = el('select', { class: 'persona-input' }, CLASSES.map((c) => el('option', { value: c }, c)))
|
| 25 |
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 26 |
const stats = el('div', { class: 'persona-stats' })
|
|
|
|
| 33 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 34 |
const thinkWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model output (raw)'), thinkEl])
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const controls = el('aside', { class: 'persona-controls' }, [
|
|
|
|
| 37 |
el('label', { class: 'persona-label' }, 'Class'), sel,
|
| 38 |
el('label', { class: 'persona-label' }, 'Seed'), seed,
|
| 39 |
btn, stats, status,
|
|
|
|
| 41 |
const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, aboutEl, thinkWrap])
|
| 42 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
| 43 |
|
|
|
|
|
|
|
| 44 |
function setTags(p) {
|
| 45 |
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 46 |
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
|
|
|
| 83 |
} catch (e) {
|
| 84 |
status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e})`
|
| 85 |
}
|
|
|
|
| 86 |
} catch (e) {
|
| 87 |
status.textContent = `couldn't run the local model: ${e.message || e}`
|
| 88 |
} finally {
|
web/settingsPanel.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Settings — the in-browser LLM engine + model picker and cache management, shared by
|
| 2 |
+
// BOTH the Personas and War Diaries pages (they read the same runtime.js singleton).
|
| 3 |
+
// Moved here out of the individual panels so there's ONE place to choose the model and
|
| 4 |
+
// it's guaranteed identical on both pages.
|
| 5 |
+
import { mountModelBar } from '/web/modelBar.js'
|
| 6 |
+
|
| 7 |
+
function el(tag, props = {}, kids = []) {
|
| 8 |
+
const n = document.createElement(tag)
|
| 9 |
+
for (const [k, v] of Object.entries(props)) {
|
| 10 |
+
if (k === 'class') n.className = v
|
| 11 |
+
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 12 |
+
else if (v != null) n.setAttribute(k, v)
|
| 13 |
+
}
|
| 14 |
+
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
|
| 15 |
+
return n
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function mountSettingsPanel(host) {
|
| 19 |
+
const modelHost = el('div')
|
| 20 |
+
const pane = el('div', { class: 'settings-pane' }, [
|
| 21 |
+
el('h2', { class: 'persona-name settings-title' }, 'Settings'),
|
| 22 |
+
el('p', { class: 'persona-about settings-intro' },
|
| 23 |
+
'Pick the in-browser LLM that writes your soldiers and their war diaries. ' +
|
| 24 |
+
'Everything runs on your device — no cloud. This choice applies to both ' +
|
| 25 |
+
'Personas and War Diaries.'),
|
| 26 |
+
el('label', { class: 'persona-label' }, 'Local LLM'),
|
| 27 |
+
modelHost,
|
| 28 |
+
el('p', { class: 'settings-note' },
|
| 29 |
+
'Models download once and are cached in your browser; use 🗑 delete to free space. ' +
|
| 30 |
+
'wllama (llama.cpp) is the most reliable; WebLLM and Transformers.js are here to benchmark.'),
|
| 31 |
+
])
|
| 32 |
+
host.appendChild(el('div', { class: 'persona-view settings-view' }, [pane]))
|
| 33 |
+
mountModelBar(modelHost)
|
| 34 |
+
}
|
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
|
@@ -118,6 +118,14 @@
|
|
| 118 |
.persona-go-alt:hover { background: var(--p-paper-2) !important; color: var(--p-ink) !important; }
|
| 119 |
.tts-status { min-height: 14px; }
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
/* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
|
| 122 |
.ctl-collapse > summary { display: none; }
|
| 123 |
|
|
|
|
| 118 |
.persona-go-alt:hover { background: var(--p-paper-2) !important; color: var(--p-ink) !important; }
|
| 119 |
.tts-status { min-height: 14px; }
|
| 120 |
|
| 121 |
+
/* ── Settings page (engine + model picker, shared by both pages) ───────────── */
|
| 122 |
+
.settings-view { background: var(--p-paper); overflow-y: auto; }
|
| 123 |
+
.settings-pane { max-width: 560px; width: 100%; margin: 0 auto; padding: 48px 28px; }
|
| 124 |
+
.settings-title { font-size: 30px; margin-bottom: 8px; }
|
| 125 |
+
.settings-intro { font-size: 15px; max-width: none; margin-bottom: 26px; }
|
| 126 |
+
.settings-note { font-family: var(--p-mono); font-size: 10px; line-height: 1.6; color: var(--p-muted); margin-top: 14px; }
|
| 127 |
+
@media (max-width: 768px) { .settings-pane { padding: 54px 16px 28px; } }
|
| 128 |
+
|
| 129 |
/* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
|
| 130 |
.ctl-collapse > summary { display: none; }
|
| 131 |
|
web/tiny.js
CHANGED
|
@@ -9,6 +9,7 @@ import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js'
|
|
| 9 |
import { mountSpritePlayground } from '/web/playground.js'
|
| 10 |
import { mountPersonaPanel } from '/web/personaPanel.js'
|
| 11 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
|
|
|
| 12 |
|
| 13 |
function whenEl(id, cb) {
|
| 14 |
const found = document.getElementById(id)
|
|
@@ -57,6 +58,8 @@ whenEl('sprite-stage', async (el) => {
|
|
| 57 |
// ── Personas + War Diary tabs — in-browser llama.cpp (wllama), runs on the device ──
|
| 58 |
whenEl('persona-stage', (el) => { mountPersonaPanel(el) })
|
| 59 |
whenEl('diary-stage', (el) => { mountDiaryPanel(el) })
|
|
|
|
|
|
|
| 60 |
|
| 61 |
// ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
|
| 62 |
const PLAYERS = [
|
|
|
|
| 9 |
import { mountSpritePlayground } from '/web/playground.js'
|
| 10 |
import { mountPersonaPanel } from '/web/personaPanel.js'
|
| 11 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 12 |
+
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 13 |
|
| 14 |
function whenEl(id, cb) {
|
| 15 |
const found = document.getElementById(id)
|
|
|
|
| 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 live in Settings (shared by both Personas and War Diaries).
|
| 62 |
+
whenEl('settings-stage', (el) => { mountSettingsPanel(el) })
|
| 63 |
|
| 64 |
// ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
|
| 65 |
const PLAYERS = [
|