polats Claude Opus 4.8 (1M context) commited on
Commit
898540a
·
1 Parent(s): cd43499

Settings: model section to top; default WebLLM + Qwen3 0.6B; copyable debug

Browse files

- Inject the "Local AI Model" section at the TOP of Gradio's settings page.
- Default engine = WebLLM (best on mobile WebGPU; falls back to wllama with no
WebGPU) and default model = Qwen3 0.6B.
- Persona + diary panels gain a "📋 Copy debug" button that copies a paste-ready
report (engine · backend · model · input · outcome · raw output · stripped text,
plus exception/stack), so failures like the empty-JSON persona can be sent back
verbatim. Clipboard-blocked contexts fall back to selecting the text.

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

web/diaryPanel.js CHANGED
@@ -2,7 +2,7 @@
2
  // diary entry generated ON THE USER'S DEVICE via the LLM facade, and can READ IT ALOUD
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'
@@ -28,6 +28,9 @@ export function mountDiaryPanel(host) {
28
  const narrateBtn = el('button', { class: 'persona-go persona-go-alt', type: 'button' }, '🔊 Read aloud')
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).
@@ -40,11 +43,36 @@ export function mountDiaryPanel(host) {
40
  btn, stats, status,
41
  ttsWrap, narrateBtn, ttsStatus,
42
  ])
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
49
  let lastBody = '' // diary text (no header), what gets narrated
50
  let narrator = null
@@ -100,6 +128,7 @@ export function mountDiaryPanel(host) {
100
  // If auto-narrate is on, prepare a live narrator before generation starts.
101
  let live = null
102
  let spokenLen = 0
 
103
  if (ttsBar.autoNarrate()) {
104
  try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
105
  catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
@@ -109,9 +138,8 @@ export function mountDiaryPanel(host) {
109
  status.textContent = `loading ${currentModel().label} into your browser…`
110
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
111
  status.textContent = `writing on your device with ${currentModel().label}…`
112
- let raw = ''
113
- await streamChat(DIARY_SYSTEM, diaryUserPrompt(unit.value, traits.value) + noThink(currentModel().id), {
114
- maxTokens: thinkMaxTokens(currentModel().id, 220), temperature: 0.9,
115
  onToken: (piece) => {
116
  raw += piece
117
  lastBody = stripThink(raw)
@@ -121,9 +149,13 @@ export function mountDiaryPanel(host) {
121
  onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
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()
128
  } finally {
129
  busy = false; btn.disabled = false
 
2
  // diary entry generated ON THE USER'S DEVICE via the LLM facade, and can READ IT ALOUD
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 } from '/web/tts.js'
8
  import { DIARY_SYSTEM, diaryUserPrompt, stripThink, noThink, thinkMaxTokens } from '/web/personaPrompts.js'
 
28
  const narrateBtn = el('button', { class: 'persona-go persona-go-alt', type: 'button' }, '🔊 Read aloud')
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
+ const dbgEl = el('pre', { class: 'persona-think' })
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
  // On phones the voice bar collapses behind a tap-to-expand summary so the story
36
  // isn't pushed off-screen; on desktop it stays open (summary hidden via CSS).
 
43
  btn, stats, status,
44
  ttsWrap, narrateBtn, ttsStatus,
45
  ])
46
+ const result = el('div', { class: 'persona-result' }, [out, dbgWrap])
47
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
48
 
49
  const ttsBar = mountTtsBar(ttsHost)
50
 
51
+ let lastDebug = ''
52
+ function buildDebug(outcome, raw) {
53
+ return [
54
+ '=== TINY ARMY · DIARY DEBUG ===',
55
+ `engine: ${getEngineId()} · ${backendLabel()}`,
56
+ `model: ${currentModelId()} (${currentModel().label})`,
57
+ `input: unit=${unit.value} traits=${traits.value} maxTokens=${thinkMaxTokens(currentModelId(), 220)}`,
58
+ `outcome: ${outcome}`,
59
+ `--- raw output (${(raw || '').length} chars) ---`,
60
+ raw || '(empty)',
61
+ ].join('\n')
62
+ }
63
+ copyBtn.addEventListener('click', async () => {
64
+ const text = lastDebug || buildDebug('(no generation yet)', '')
65
+ try {
66
+ await navigator.clipboard.writeText(text)
67
+ copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
68
+ } catch {
69
+ dbgEl.textContent = text; dbgWrap.open = true
70
+ const r = document.createRange(); r.selectNodeContents(dbgEl)
71
+ const s = getSelection(); s.removeAllRanges(); s.addRange(r)
72
+ copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
73
+ }
74
+ })
75
+
76
  let busy = false
77
  let lastBody = '' // diary text (no header), what gets narrated
78
  let narrator = null
 
128
  // If auto-narrate is on, prepare a live narrator before generation starts.
129
  let live = null
130
  let spokenLen = 0
131
+ let raw = ''
132
  if (ttsBar.autoNarrate()) {
133
  try { setSpeaking(true); narrator = live = await makeReadyNarrator() }
134
  catch (e) { setSpeaking(false); ttsStatus.textContent = `voice unavailable: ${e.message || e}` }
 
138
  status.textContent = `loading ${currentModel().label} into your browser…`
139
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
140
  status.textContent = `writing on your device with ${currentModel().label}…`
141
+ await streamChat(DIARY_SYSTEM, diaryUserPrompt(unit.value, traits.value) + noThink(currentModelId()), {
142
+ maxTokens: thinkMaxTokens(currentModelId(), 220), temperature: 0.9,
 
143
  onToken: (piece) => {
144
  raw += piece
145
  lastBody = stripThink(raw)
 
149
  onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
150
  })
151
  status.textContent = 'written ✓ (generated locally)'
152
+ lastDebug = buildDebug(lastBody.trim() ? 'written OK' : 'EMPTY OUTPUT', raw)
153
+ dbgEl.textContent = raw
154
  if (live) live.end() // flush the tail sentence; onState resets the button
155
  } catch (e) {
156
+ status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
157
+ lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), raw)
158
+ dbgEl.textContent = raw; dbgWrap.open = true
159
  if (live) live.stop()
160
  } finally {
161
  busy = false; btn.disabled = false
web/engineWebllm.js CHANGED
@@ -82,7 +82,7 @@ export const engine = {
82
  requiresWebGPU: true,
83
  available: () => hasGPU(),
84
  models: MODELS,
85
- defaultModel: 'qwen2.5-0.5b',
86
  ensure, stream,
87
  backendLabel: () => (hasGPU() ? '⚡ WebGPU' : 'needs WebGPU'),
88
  // Cache list/delete via MLC's own helpers (Cache API or IndexedDB, per appConfig).
 
82
  requiresWebGPU: true,
83
  available: () => hasGPU(),
84
  models: MODELS,
85
+ defaultModel: 'qwen3-0.6b',
86
  ensure, stream,
87
  backendLabel: () => (hasGPU() ? '⚡ WebGPU' : 'needs WebGPU'),
88
  // Cache list/delete via MLC's own helpers (Cache API or IndexedDB, per appConfig).
web/personaPanel.js CHANGED
@@ -1,8 +1,8 @@
1
  // Tiny Army persona panel — vanilla DOM, mounted by tiny.js into #persona-stage.
2
- // Generation runs ON THE USER'S DEVICE via wllama (llama.cpp WASM). Model is pickable
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'
@@ -29,9 +29,11 @@ export function mountPersonaPanel(host) {
29
 
30
  const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
31
  const tagsEl = el('div', { class: 'persona-tags' })
32
- const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small llama.cpp model in your browser writes their legend.')
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,
@@ -49,6 +51,37 @@ export function mountPersonaPanel(host) {
49
  stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}`
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  let busy = false
53
  async function generate() {
54
  if (busy) return
@@ -56,13 +89,13 @@ export function mountPersonaPanel(host) {
56
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
57
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
58
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
 
59
  try {
60
  status.textContent = `loading ${currentModel().label} into your browser…`
61
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
62
  status.textContent = `writing on your device with ${currentModel().label}…`
63
- let acc = ''
64
- await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModel().id), {
65
- maxTokens: thinkMaxTokens(currentModel().id, 220),
66
  onToken: (piece) => {
67
  acc += piece
68
  thinkEl.textContent = acc // raw view shows the model's <think> reasoning too
@@ -79,12 +112,17 @@ export function mountPersonaPanel(host) {
79
  aboutEl.textContent = p.about
80
  setTags(p)
81
  status.textContent = 'enlisted ✓ (generated locally)'
 
82
  thinkWrap.open = false
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 {
89
  busy = false; btn.disabled = false
90
  }
 
1
  // Tiny Army persona panel — vanilla DOM, mounted by tiny.js into #persona-stage.
2
+ // Generation runs ON THE USER'S DEVICE via the chosen engine (Settings). Streams into a
3
+ // live "thinking" view + parsed result, shows tok/s, and exposes a one-tap "Copy debug"
4
+ // report (engine/model/raw output/error) so failures can be pasted back for triage.
5
+ import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } 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'
 
29
 
30
  const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
31
  const tagsEl = el('div', { class: 'persona-tags' })
32
+ const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small model in your browser writes their legend.')
33
  const thinkEl = el('pre', { class: 'persona-think' })
34
+ const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
35
+ const thinkWrap = el('details', { class: 'persona-think-wrap' },
36
+ [el('summary', {}, 'model output / debug (raw)'), copyBtn, thinkEl])
37
 
38
  const controls = el('aside', { class: 'persona-controls' }, [
39
  el('label', { class: 'persona-label' }, 'Class'), sel,
 
51
  stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}`
52
  }
53
 
54
+ // A self-contained, paste-ready report of the last run.
55
+ let lastDebug = ''
56
+ function buildDebug(outcome, acc) {
57
+ const stripped = stripThink(acc || '')
58
+ return [
59
+ '=== TINY ARMY · PERSONA DEBUG ===',
60
+ `engine: ${getEngineId()} · ${backendLabel()}`,
61
+ `model: ${currentModelId()} (${currentModel().label})`,
62
+ `input: class=${sel.value} seed=${seed.value || '(none)'} maxTokens=${thinkMaxTokens(currentModelId(), 220)}`,
63
+ `outcome: ${outcome}`,
64
+ `--- raw output (${(acc || '').length} chars) ---`,
65
+ acc || '(empty)',
66
+ `--- after stripThink → parser (${stripped.length} chars) ---`,
67
+ stripped || '(empty)',
68
+ ].join('\n')
69
+ }
70
+ copyBtn.addEventListener('click', async () => {
71
+ const text = lastDebug || buildDebug('(no generation yet)', '')
72
+ try {
73
+ await navigator.clipboard.writeText(text)
74
+ copyBtn.textContent = '✓ copied'
75
+ setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
76
+ } catch {
77
+ // Clipboard blocked (insecure context / permissions) — show it selected to copy by hand.
78
+ thinkEl.textContent = text; thinkWrap.open = true
79
+ const r = document.createRange(); r.selectNodeContents(thinkEl)
80
+ const s = getSelection(); s.removeAllRanges(); s.addRange(r)
81
+ copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
82
+ }
83
+ })
84
+
85
  let busy = false
86
  async function generate() {
87
  if (busy) return
 
89
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
90
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
91
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
92
+ let acc = ''
93
  try {
94
  status.textContent = `loading ${currentModel().label} into your browser…`
95
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
96
  status.textContent = `writing on your device with ${currentModel().label}…`
97
+ await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
98
+ maxTokens: thinkMaxTokens(currentModelId(), 220),
 
99
  onToken: (piece) => {
100
  acc += piece
101
  thinkEl.textContent = acc // raw view shows the model's <think> reasoning too
 
112
  aboutEl.textContent = p.about
113
  setTags(p)
114
  status.textContent = 'enlisted ✓ (generated locally)'
115
+ lastDebug = buildDebug('parsed OK', acc)
116
  thinkWrap.open = false
117
  } catch (e) {
118
+ status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e}) · 📋 Copy debug`
119
+ lastDebug = buildDebug('PARSE ERROR: ' + (e.message || e), acc)
120
+ thinkWrap.open = true
121
  }
122
  } catch (e) {
123
+ status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
124
+ lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc)
125
+ thinkWrap.open = true
126
  } finally {
127
  busy = false; btn.disabled = false
128
  }
web/runtime.js CHANGED
@@ -8,7 +8,9 @@ import { engine as webllm } from '/web/engineWebllm.js'
8
  import { ensurePersistentStorage } from '/web/storage.js'
9
 
10
  const ENGINES = [wllama, transformers, webllm]
11
- let activeId = 'wllama'
 
 
12
  const modelSel = {} // engineId -> chosen model id (remembered per engine)
13
 
14
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
 
8
  import { ensurePersistentStorage } from '/web/storage.js'
9
 
10
  const ENGINES = [wllama, transformers, webllm]
11
+ // Default to WebLLM (fastest on mobile WebGPU); fall back to wllama where there's no
12
+ // WebGPU so the app still works. Both default to Qwen3 0.6B (see each engine).
13
+ let activeId = webllm.available() ? 'webllm' : 'wllama'
14
  const modelSel = {} // engineId -> chosen model id (remembered per engine)
15
 
16
  const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
web/settingsPanel.js CHANGED
@@ -32,7 +32,7 @@ function injectInto(sampleSection) {
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.appendChild(section)
36
  mountModelBar(modelHost)
37
  }
38
 
 
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, list.firstChild) // top of the settings page, above Display Theme
36
  mountModelBar(modelHost)
37
  }
38
 
web/shell/persona.css CHANGED
@@ -102,6 +102,12 @@
102
  font-family: var(--p-mono); font-size: 11px; line-height: 1.5; color: var(--p-muted);
103
  background: var(--p-paper-2); border: 1px solid var(--p-ink); padding: 8px 10px;
104
  }
 
 
 
 
 
 
105
 
106
  /* ── TTS / voice controls (war-diary read-aloud) ───────────────────────────── */
107
  .tts-bar { margin-top: 16px; }
 
102
  font-family: var(--p-mono); font-size: 11px; line-height: 1.5; color: var(--p-muted);
103
  background: var(--p-paper-2); border: 1px solid var(--p-ink); padding: 8px 10px;
104
  }
105
+ .persona-copy {
106
+ margin-top: 8px; font-family: var(--p-mono) !important; font-size: 10px !important; letter-spacing: .04em; text-transform: uppercase;
107
+ color: var(--p-transmit) !important; background: var(--p-card) !important; border: 1.5px solid var(--p-transmit) !important;
108
+ border-radius: 0 !important; padding: 5px 9px !important; cursor: pointer;
109
+ }
110
+ .persona-copy:hover { background: var(--p-transmit) !important; color: var(--p-card) !important; }
111
 
112
  /* ── TTS / voice controls (war-diary read-aloud) ───────────────────────────── */
113
  .tts-bar { margin-top: 16px; }