polats Claude Opus 4.8 (1M context) commited on
Commit
627d835
·
1 Parent(s): b0f48f8

Move engine/model settings to a Settings page; share one model across both pages

Browse files

The 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 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 model + voice bars collapse behind tap-to-expand summaries so the
35
- // story isn't pushed off-screen; on desktop they stay open (summary hidden via CSS).
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
- modelWrap.open = ttsWrap.open = window.innerWidth > 768
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 = [