Spaces:
Running
Running
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 +38 -6
- web/engineWebllm.js +1 -1
- web/personaPanel.js +49 -11
- web/runtime.js +3 -1
- web/settingsPanel.js +1 -1
- web/shell/persona.css +6 -0
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 |
-
|
| 113 |
-
|
| 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: '
|
| 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
|
| 3 |
-
//
|
| 4 |
-
//
|
| 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
|
| 33 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 34 |
-
const
|
|
|
|
|
|
|
| 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 |
-
|
| 64 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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; }
|