Spaces:
Running
Settings: recommended quality presets + device readout; persist all choices
Browse filesNew "Recommended settings" section at the top of Settings (above Local AI Model):
- High / Medium / Low presets that set the LLM model AND voice provider together,
like a game's graphics presets:
High = Qwen3 0.6B + Kokoro 82M
Medium = Qwen2.5 0.5B + Kitten TTS
Low = Qwen2.5 0.5B + Web Speech
Presets resolve the model within the active engine's catalog (family fallback).
Changing the model or voice by hand drops to Custom.
- Expandable "Your device" readout (CPU threads, RAM, WebGPU/GPU, storage, screen,
UA) for debugging and benchmarking.
Persistence: the LLM engine + per-engine model (runtime.js), the voice provider
(tts.js), and the preset choice (qualityPreset.js) are all saved to localStorage,
so settings survive a refresh. runtime.js now validates a stored model against the
active engine and emits onModelChange; the model/voice bars re-sync when a preset
changes them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/deviceInfo.js +46 -0
- web/modelBar.js +5 -1
- web/qualityBar.js +53 -0
- web/qualityPreset.js +70 -0
- web/runtime.js +35 -7
- web/settingsPanel.js +6 -1
- web/shell/persona.css +25 -0
- web/tts.js +9 -2
- web/ttsBar.js +3 -1
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Best-effort device/GPU/RAM probe for the Settings "Your device" panel — handy for
|
| 2 |
+
// debugging and benchmarking which quality preset a machine can handle. Everything is
|
| 3 |
+
// guarded: unsupported APIs just read "—" rather than throwing.
|
| 4 |
+
import { storageEstimate } from '/web/storage.js'
|
| 5 |
+
import { fmtBytes } from '/web/modelCatalog.js'
|
| 6 |
+
|
| 7 |
+
function webglRenderer() {
|
| 8 |
+
try {
|
| 9 |
+
const c = document.createElement('canvas')
|
| 10 |
+
const gl = c.getContext('webgl') || c.getContext('experimental-webgl')
|
| 11 |
+
if (!gl) return '—'
|
| 12 |
+
const dbg = gl.getExtension('WEBGL_debug_renderer_info')
|
| 13 |
+
return dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : (gl.getParameter(gl.RENDERER) || '—')
|
| 14 |
+
} catch { return '—' }
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
async function webgpuInfo() {
|
| 18 |
+
try {
|
| 19 |
+
if (!navigator.gpu) return 'unavailable'
|
| 20 |
+
const ad = await navigator.gpu.requestAdapter()
|
| 21 |
+
if (!ad) return 'no adapter'
|
| 22 |
+
const info = ad.info || (ad.requestAdapterInfo ? await ad.requestAdapterInfo() : null)
|
| 23 |
+
const desc = info ? [info.vendor, info.architecture, info.description].filter(Boolean).join(' ').trim() : ''
|
| 24 |
+
return desc || 'available'
|
| 25 |
+
} catch { return 'error' }
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Returns an ordered array of [label, value] rows.
|
| 29 |
+
export async function gatherDeviceInfo() {
|
| 30 |
+
const rows = []
|
| 31 |
+
const add = (k, v) => rows.push([k, v == null || v === '' ? '—' : String(v)])
|
| 32 |
+
|
| 33 |
+
add('CPU threads', navigator.hardwareConcurrency)
|
| 34 |
+
add('Device memory', navigator.deviceMemory ? `${navigator.deviceMemory} GB (approx)` : '— (not reported)')
|
| 35 |
+
add('WebGPU', await webgpuInfo())
|
| 36 |
+
add('GPU (WebGL)', webglRenderer())
|
| 37 |
+
try {
|
| 38 |
+
const { usage, quota } = await storageEstimate()
|
| 39 |
+
add('Storage cache', quota ? `${fmtBytes(usage || 0)} / ${fmtBytes(quota)}` : '—')
|
| 40 |
+
} catch { add('Storage cache', '—') }
|
| 41 |
+
try { add('Screen', `${screen.width}×${screen.height} @ ${window.devicePixelRatio || 1}×`) } catch { add('Screen', '—') }
|
| 42 |
+
add('Platform', (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform)
|
| 43 |
+
add('Language', navigator.language)
|
| 44 |
+
add('User agent', navigator.userAgent)
|
| 45 |
+
return rows
|
| 46 |
+
}
|
|
@@ -6,7 +6,7 @@
|
|
| 6 |
import {
|
| 7 |
listEngines, getEngineId, setEngine,
|
| 8 |
listModels, currentModel, setModel,
|
| 9 |
-
cacheSupported, cachedSet, deleteCached, backendLabel,
|
| 10 |
} from '/web/runtime.js'
|
| 11 |
import { fmtBytes } from '/web/modelCatalog.js'
|
| 12 |
import { storageEstimate } from '/web/storage.js'
|
|
@@ -76,6 +76,10 @@ export function mountModelBar(host, { onChange } = {}) {
|
|
| 76 |
if (info.textContent.startsWith('delete failed')) setTimeout(() => { info.textContent = prev }, 2500)
|
| 77 |
})
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
render()
|
| 80 |
refresh()
|
| 81 |
return { refresh }
|
|
|
|
| 6 |
import {
|
| 7 |
listEngines, getEngineId, setEngine,
|
| 8 |
listModels, currentModel, setModel,
|
| 9 |
+
cacheSupported, cachedSet, deleteCached, backendLabel, onModelChange,
|
| 10 |
} from '/web/runtime.js'
|
| 11 |
import { fmtBytes } from '/web/modelCatalog.js'
|
| 12 |
import { storageEstimate } from '/web/storage.js'
|
|
|
|
| 76 |
if (info.textContent.startsWith('delete failed')) setTimeout(() => { info.textContent = prev }, 2500)
|
| 77 |
})
|
| 78 |
|
| 79 |
+
// A preset (or any other surface) may change the engine/model — re-sync this bar.
|
| 80 |
+
// render() updates the selects immediately; refresh() then refreshes cache/size info.
|
| 81 |
+
onModelChange(() => { engSel.value = getEngineId(); render(); refresh() })
|
| 82 |
+
|
| 83 |
render()
|
| 84 |
refresh()
|
| 85 |
return { refresh }
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// "Recommended settings" — a High/Medium/Low/Custom preset picker (sets the LLM model +
|
| 2 |
+
// voice provider together) plus an expandable "Your device" readout for debugging and
|
| 3 |
+
// benchmarking. Mounted at the TOP of the Settings page, above Local AI Model.
|
| 4 |
+
import { PRESETS, applyPreset, getActivePreset, onPresetChange } from '/web/qualityPreset.js'
|
| 5 |
+
import { gatherDeviceInfo } from '/web/deviceInfo.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 mountQualityBar(host) {
|
| 19 |
+
// Preset buttons (+ a derived "Custom" that only highlights, never applies).
|
| 20 |
+
const OPTS = [...PRESETS, { id: 'custom', label: 'Custom', sub: 'Your own model + voice' }]
|
| 21 |
+
const btns = {}
|
| 22 |
+
const row = el('div', { class: 'tac-preset-row' }, OPTS.map((p) => {
|
| 23 |
+
const b = el('button', { class: 'tac-preset-btn', type: 'button', 'data-id': p.id },
|
| 24 |
+
[el('span', { class: 'tac-preset-name' }, p.label), el('span', { class: 'tac-preset-sub' }, p.sub)])
|
| 25 |
+
if (p.id !== 'custom') b.addEventListener('click', () => applyPreset(p.id))
|
| 26 |
+
else b.disabled = true // Custom is a status, not an action
|
| 27 |
+
btns[p.id] = b
|
| 28 |
+
return b
|
| 29 |
+
}))
|
| 30 |
+
|
| 31 |
+
function highlight(active) {
|
| 32 |
+
for (const id of Object.keys(btns)) btns[id].classList.toggle('active', id === active)
|
| 33 |
+
}
|
| 34 |
+
highlight(getActivePreset())
|
| 35 |
+
onPresetChange((id) => highlight(id))
|
| 36 |
+
|
| 37 |
+
// Expandable device readout (debug / benchmark).
|
| 38 |
+
const devBody = el('div', { class: 'tac-device-body' }, 'reading…')
|
| 39 |
+
const dev = el('details', { class: 'tac-device' },
|
| 40 |
+
[el('summary', {}, 'Your device (GPU / RAM / storage)'), devBody])
|
| 41 |
+
dev.addEventListener('toggle', async () => {
|
| 42 |
+
if (!dev.open || dev.dataset.loaded) return
|
| 43 |
+
dev.dataset.loaded = '1'
|
| 44 |
+
try {
|
| 45 |
+
const rows = await gatherDeviceInfo()
|
| 46 |
+
devBody.replaceChildren(...rows.map(([k, v]) =>
|
| 47 |
+
el('div', { class: 'tac-device-row' }, [el('span', { class: 'tac-device-k' }, k), el('span', { class: 'tac-device-v' }, v)])))
|
| 48 |
+
} catch { devBody.textContent = 'could not read device info' }
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
host.append(el('div', { class: 'tac-quality' }, [row, dev]))
|
| 52 |
+
return { refresh: () => highlight(detectPreset()) }
|
| 53 |
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Quality presets — a game-graphics-style High/Medium/Low/Custom picker that sets BOTH
|
| 2 |
+
// the in-browser LLM model and the voice provider at once. Picking a preset applies it;
|
| 3 |
+
// changing the model or voice by hand drops the picker to "Custom" (even if the new combo
|
| 4 |
+
// happens to equal another preset — like a game's graphics dropdown). The choice is
|
| 5 |
+
// persisted (and the model/voice themselves are persisted by runtime.js / tts.js), so it
|
| 6 |
+
// survives a refresh.
|
| 7 |
+
import { setModel, currentModelId, listModels, onModelChange } from '/web/runtime.js'
|
| 8 |
+
import { setTtsEngine, getTtsEngineId, onTtsEngineChange } from '/web/tts.js'
|
| 9 |
+
|
| 10 |
+
// model = preferred exact id; family = prefix fallback if the active engine lacks it
|
| 11 |
+
// (e.g. Transformers.js has no Qwen3 — then High can't be honored and reads as Custom).
|
| 12 |
+
export const PRESETS = [
|
| 13 |
+
{ id: 'high', label: 'High', model: 'qwen3-0.6b', family: 'qwen3', voice: 'kokoro', sub: 'Qwen3 · Kokoro 82M' },
|
| 14 |
+
{ id: 'medium', label: 'Medium', model: 'qwen2.5-0.5b', family: 'qwen2.5', voice: 'kitten', sub: 'Qwen2.5 · Kitten TTS' },
|
| 15 |
+
{ id: 'low', label: 'Low', model: 'qwen2.5-0.5b', family: 'qwen2.5', voice: 'webspeech', sub: 'Qwen2.5 · Web Speech' },
|
| 16 |
+
]
|
| 17 |
+
const KEY = 'tinyarmy.qualityPreset'
|
| 18 |
+
const VALID = new Set([...PRESETS.map((p) => p.id), 'custom'])
|
| 19 |
+
|
| 20 |
+
const byId = (id) => PRESETS.find((p) => p.id === id)
|
| 21 |
+
// Resolve a preset's model to an id that exists in the ACTIVE engine's catalog.
|
| 22 |
+
function resolveModel(p) {
|
| 23 |
+
const ms = listModels()
|
| 24 |
+
const m = ms.find((x) => x.id === p.model) || ms.find((x) => x.id.startsWith(p.family))
|
| 25 |
+
return m ? m.id : ''
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Which preset does the current (model, voice) match? 'custom' if none.
|
| 29 |
+
export function detectPreset() {
|
| 30 |
+
const mid = currentModelId(), v = getTtsEngineId()
|
| 31 |
+
const p = PRESETS.find((x) => (mid === x.model || mid.startsWith(x.family)) && v === x.voice)
|
| 32 |
+
return p ? p.id : 'custom'
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
let _applying = false
|
| 36 |
+
// The active preset id. Load the saved one, but only trust a saved preset id if the
|
| 37 |
+
// persisted model+voice still match it; honor a saved 'custom' as-is.
|
| 38 |
+
let _current = (() => {
|
| 39 |
+
let saved = ''
|
| 40 |
+
try { saved = localStorage.getItem(KEY) || '' } catch { /* ignore */ }
|
| 41 |
+
if (!VALID.has(saved)) return detectPreset()
|
| 42 |
+
if (saved === 'custom') return 'custom'
|
| 43 |
+
return detectPreset() === saved ? saved : detectPreset()
|
| 44 |
+
})()
|
| 45 |
+
|
| 46 |
+
export const getActivePreset = () => _current
|
| 47 |
+
|
| 48 |
+
function commit(id) {
|
| 49 |
+
_current = id
|
| 50 |
+
try { localStorage.setItem(KEY, id) } catch { /* ignore */ }
|
| 51 |
+
for (const fn of _listeners) { try { fn(id) } catch { /* ignore */ } }
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function applyPreset(id) {
|
| 55 |
+
const p = byId(id)
|
| 56 |
+
if (!p) return
|
| 57 |
+
_applying = true
|
| 58 |
+
const mid = resolveModel(p)
|
| 59 |
+
if (mid) setModel(mid) // fires onModelChange (ignored while _applying)
|
| 60 |
+
setTtsEngine(p.voice) // fires onTtsEngineChange (ignored while _applying)
|
| 61 |
+
_applying = false
|
| 62 |
+
commit(id)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const _listeners = new Set()
|
| 66 |
+
export function onPresetChange(fn) { _listeners.add(fn); return () => _listeners.delete(fn) }
|
| 67 |
+
// A model/voice change NOT caused by applyPreset = a manual edit → Custom.
|
| 68 |
+
const _onManual = () => { if (!_applying) commit('custom') }
|
| 69 |
+
onModelChange(_onManual)
|
| 70 |
+
onTtsEngineChange(_onManual)
|
|
@@ -8,21 +8,49 @@ import { engine as webllm } from '/web/engineWebllm.js'
|
|
| 8 |
import { ensurePersistentStorage } from '/web/storage.js'
|
| 9 |
|
| 10 |
const ENGINES = [wllama, transformers, webllm]
|
| 11 |
-
//
|
| 12 |
-
//
|
| 13 |
-
|
| 14 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
|
| 17 |
|
| 18 |
export const listEngines = () => ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available() }))
|
| 19 |
export const getEngineId = () => activeId
|
| 20 |
-
export function setEngine(id) {
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
export const listModels = () => eng().models
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export const currentModel = () => eng().models.find((m) => m.id === currentModelId()) || eng().models[0]
|
| 25 |
-
export function setModel(id) {
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
export const ensureModel = async (onProgress) => {
|
| 28 |
await ensurePersistentStorage() // keep downloads from being evicted across engine switches
|
|
|
|
| 8 |
import { ensurePersistentStorage } from '/web/storage.js'
|
| 9 |
|
| 10 |
const ENGINES = [wllama, transformers, webllm]
|
| 11 |
+
// Persisted choices (survive refresh). Defaults: WebLLM where there's WebGPU (fastest),
|
| 12 |
+
// else wllama so the app still works without it.
|
| 13 |
+
const ENGINE_KEY = 'tinyarmy.llmEngine', MODELS_KEY = 'tinyarmy.llmModels'
|
| 14 |
+
const loadJSON = (k, fb) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb } catch { return fb } }
|
| 15 |
+
const loadStr = (k) => { try { return localStorage.getItem(k) || '' } catch { return '' } }
|
| 16 |
+
|
| 17 |
+
let activeId = (() => {
|
| 18 |
+
const saved = loadStr(ENGINE_KEY)
|
| 19 |
+
const e = ENGINES.find((x) => x.id === saved)
|
| 20 |
+
return e && e.available() ? saved : (webllm.available() ? 'webllm' : 'wllama')
|
| 21 |
+
})()
|
| 22 |
+
const modelSel = loadJSON(MODELS_KEY, {}) // engineId -> chosen model id (remembered per engine)
|
| 23 |
+
|
| 24 |
+
function persist() {
|
| 25 |
+
try { localStorage.setItem(ENGINE_KEY, activeId); localStorage.setItem(MODELS_KEY, JSON.stringify(modelSel)) } catch { /* ignore */ }
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Change listeners (the Settings "Recommended" preset bar + the model bar re-render).
|
| 29 |
+
const _listeners = new Set()
|
| 30 |
+
export function onModelChange(fn) { _listeners.add(fn); return () => _listeners.delete(fn) }
|
| 31 |
+
const _notify = () => { for (const fn of _listeners) { try { fn() } catch { /* ignore */ } } }
|
| 32 |
|
| 33 |
const eng = () => ENGINES.find((e) => e.id === activeId) || ENGINES[0]
|
| 34 |
|
| 35 |
export const listEngines = () => ENGINES.map((e) => ({ id: e.id, label: e.label, available: e.available() }))
|
| 36 |
export const getEngineId = () => activeId
|
| 37 |
+
export function setEngine(id) {
|
| 38 |
+
if (!ENGINES.some((e) => e.id === id) || id === activeId) return
|
| 39 |
+
activeId = id; persist(); _notify()
|
| 40 |
+
}
|
| 41 |
|
| 42 |
export const listModels = () => eng().models
|
| 43 |
+
// A stored model id only counts if it actually exists in the active engine's catalog
|
| 44 |
+
// (otherwise fall back to that engine's default — handles cross-engine presets cleanly).
|
| 45 |
+
export const currentModelId = () => {
|
| 46 |
+
const sel = modelSel[activeId]
|
| 47 |
+
return (sel && eng().models.some((m) => m.id === sel)) ? sel : eng().defaultModel
|
| 48 |
+
}
|
| 49 |
export const currentModel = () => eng().models.find((m) => m.id === currentModelId()) || eng().models[0]
|
| 50 |
+
export function setModel(id) {
|
| 51 |
+
if (modelSel[activeId] === id) return
|
| 52 |
+
modelSel[activeId] = id; persist(); _notify()
|
| 53 |
+
}
|
| 54 |
|
| 55 |
export const ensureModel = async (onProgress) => {
|
| 56 |
await ensurePersistentStorage() // keep downloads from being evicted across engine switches
|
|
@@ -9,6 +9,7 @@
|
|
| 9 |
import { mountModelBar } from '/web/modelBar.js'
|
| 10 |
import { mountTtsBar } from '/web/ttsBar.js'
|
| 11 |
import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
|
|
|
|
| 12 |
|
| 13 |
function el(tag, props = {}, kids = []) {
|
| 14 |
const n = document.createElement(tag)
|
|
@@ -38,7 +39,11 @@ export function mountSettingsPanel() {
|
|
| 38 |
const tryInject = () => {
|
| 39 |
const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
|
| 40 |
if (!sample) return
|
| 41 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
injectSection(sample, 'tac-model-settings', 'Local AI Model',
|
| 43 |
'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
|
| 44 |
'your device; models cache in your browser.', mountModelBar)
|
|
|
|
| 9 |
import { mountModelBar } from '/web/modelBar.js'
|
| 10 |
import { mountTtsBar } from '/web/ttsBar.js'
|
| 11 |
import { mountPersonaPromptBar } from '/web/personaPromptBar.js'
|
| 12 |
+
import { mountQualityBar } from '/web/qualityBar.js'
|
| 13 |
|
| 14 |
function el(tag, props = {}, kids = []) {
|
| 15 |
const n = document.createElement(tag)
|
|
|
|
| 39 |
const tryInject = () => {
|
| 40 |
const sample = [...document.querySelectorAll('.banner-wrap')].find((e) => /Display Theme/i.test(e.textContent))
|
| 41 |
if (!sample) return
|
| 42 |
+
// Recommended (preset) first so it sits at the very top, then Model, Voice, etc.
|
| 43 |
+
// Each injectSection inserts just above Display Theme, so call order = on-screen order.
|
| 44 |
+
injectSection(sample, 'tac-quality-settings', 'Recommended settings',
|
| 45 |
+
'Pick a quality preset — it sets the AI model and voice together, like graphics ' +
|
| 46 |
+
'presets in a game. Changing either by hand switches to Custom.', mountQualityBar)
|
| 47 |
injectSection(sample, 'tac-model-settings', 'Local AI Model',
|
| 48 |
'The in-browser model that writes your soldiers and their war diaries. Runs on ' +
|
| 49 |
'your device; models cache in your browser.', mountModelBar)
|
|
@@ -257,6 +257,31 @@
|
|
| 257 |
.tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
|
| 258 |
.tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
/* Persona-prompt editor (Settings → Persona Prompt). */
|
| 261 |
.persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; width: 100%; }
|
| 262 |
.persona-prompt-edit {
|
|
|
|
| 257 |
.tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
|
| 258 |
.tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
|
| 259 |
|
| 260 |
+
/* Recommended settings — quality preset picker (High / Medium / Low / Custom). */
|
| 261 |
+
.tac-quality { display: flex; flex-direction: column; gap: 12px; }
|
| 262 |
+
.tac-preset-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 263 |
+
.tac-preset-btn {
|
| 264 |
+
flex: 1 1 120px; display: flex; flex-direction: column; gap: 3px; text-align: left;
|
| 265 |
+
background: var(--p-card); border: 1.5px solid var(--p-ink); border-radius: 0;
|
| 266 |
+
padding: 9px 11px; cursor: pointer; color: var(--p-ink); transition: background .12s;
|
| 267 |
+
}
|
| 268 |
+
.tac-preset-btn:hover:not(:disabled) { background: var(--p-paper-2); }
|
| 269 |
+
.tac-preset-btn.active { background: var(--p-ink); color: var(--p-paper); box-shadow: 2px 2px 0 var(--p-transmit); }
|
| 270 |
+
.tac-preset-btn:disabled { cursor: default; }
|
| 271 |
+
.tac-preset-name { font-family: var(--p-mono); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
| 272 |
+
.tac-preset-sub { font-family: var(--p-mono); font-size: 10px; opacity: .7; }
|
| 273 |
+
|
| 274 |
+
/* Expandable device readout (debug / benchmark). */
|
| 275 |
+
.tac-device { border: 1px dashed var(--p-ink); padding: 8px 10px; }
|
| 276 |
+
.tac-device > summary {
|
| 277 |
+
cursor: pointer; font-family: var(--p-mono); font-size: 11px; letter-spacing: .08em;
|
| 278 |
+
text-transform: uppercase; color: var(--p-transmit);
|
| 279 |
+
}
|
| 280 |
+
.tac-device-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
| 281 |
+
.tac-device-row { display: flex; gap: 10px; font-family: var(--p-mono); font-size: 11px; line-height: 1.4; }
|
| 282 |
+
.tac-device-k { flex: 0 0 130px; color: var(--p-muted); }
|
| 283 |
+
.tac-device-v { flex: 1; min-width: 0; color: var(--p-ink); word-break: break-word; }
|
| 284 |
+
|
| 285 |
/* Persona-prompt editor (Settings → Persona Prompt). */
|
| 286 |
.persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; width: 100%; }
|
| 287 |
.persona-prompt-edit {
|
|
@@ -12,8 +12,14 @@ import { ensurePersistentStorage } from '/web/storage.js'
|
|
| 12 |
const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
|
| 13 |
// Default voice provider: local-GPU Qwen3-TTS on localhost (your GPU designs voices),
|
| 14 |
// in-browser Kokoro in prod (runs on the device — no exhaustible cloud quota). Cloud
|
| 15 |
-
// Qwen3-TTS and the others remain selectable in Settings.
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
// Qwen3-TTS designs a voice from a free-form description (the persona's `voice`).
|
| 19 |
// Panels set it before narrating; previewVoice() plays a one-off sample.
|
|
@@ -82,6 +88,7 @@ export function onTtsEngineChange(fn) { _engineListeners.add(fn); return () => _
|
|
| 82 |
export function setTtsEngine(id) {
|
| 83 |
if (!ENGINES.some((e) => e.id === id) || id === activeId) return
|
| 84 |
activeId = id
|
|
|
|
| 85 |
for (const fn of _engineListeners) { try { fn(id) } catch { /* ignore */ } }
|
| 86 |
}
|
| 87 |
|
|
|
|
| 12 |
const ENGINES = [kokoro, qwen3local, qwen3, kitten, webspeech]
|
| 13 |
// Default voice provider: local-GPU Qwen3-TTS on localhost (your GPU designs voices),
|
| 14 |
// in-browser Kokoro in prod (runs on the device — no exhaustible cloud quota). Cloud
|
| 15 |
+
// Qwen3-TTS and the others remain selectable in Settings. Persisted across refreshes.
|
| 16 |
+
const TTS_ENGINE_KEY = 'tinyarmy.ttsEngine'
|
| 17 |
+
let activeId = (() => {
|
| 18 |
+
let saved = ''
|
| 19 |
+
try { saved = localStorage.getItem(TTS_ENGINE_KEY) || '' } catch { /* ignore */ }
|
| 20 |
+
const e = ENGINES.find((x) => x.id === saved)
|
| 21 |
+
return e && e.available() ? saved : (isLocalhost() ? 'qwen3local' : 'kokoro')
|
| 22 |
+
})()
|
| 23 |
|
| 24 |
// Qwen3-TTS designs a voice from a free-form description (the persona's `voice`).
|
| 25 |
// Panels set it before narrating; previewVoice() plays a one-off sample.
|
|
|
|
| 88 |
export function setTtsEngine(id) {
|
| 89 |
if (!ENGINES.some((e) => e.id === id) || id === activeId) return
|
| 90 |
activeId = id
|
| 91 |
+
try { localStorage.setItem(TTS_ENGINE_KEY, id) } catch { /* ignore */ }
|
| 92 |
for (const fn of _engineListeners) { try { fn(id) } catch { /* ignore */ } }
|
| 93 |
}
|
| 94 |
|
|
@@ -5,7 +5,7 @@
|
|
| 5 |
// the shared tts.js facade; every page reads that choice.
|
| 6 |
import {
|
| 7 |
listTtsEngines, getTtsEngineId, setTtsEngine,
|
| 8 |
-
ttsBackendLabel, ttsNeedsDownload, activeEngineIsDesign,
|
| 9 |
} from '/web/tts.js'
|
| 10 |
|
| 11 |
function el(tag, props = {}, kids = []) {
|
|
@@ -40,6 +40,8 @@ export function mountTtsBar(host, { onChange } = {}) {
|
|
| 40 |
}
|
| 41 |
|
| 42 |
engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderInfo(); onChange && onChange() })
|
|
|
|
|
|
|
| 43 |
|
| 44 |
renderInfo()
|
| 45 |
return { refresh: renderInfo }
|
|
|
|
| 5 |
// the shared tts.js facade; every page reads that choice.
|
| 6 |
import {
|
| 7 |
listTtsEngines, getTtsEngineId, setTtsEngine,
|
| 8 |
+
ttsBackendLabel, ttsNeedsDownload, activeEngineIsDesign, onTtsEngineChange,
|
| 9 |
} from '/web/tts.js'
|
| 10 |
|
| 11 |
function el(tag, props = {}, kids = []) {
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
engSel.addEventListener('change', () => { setTtsEngine(engSel.value); renderInfo(); onChange && onChange() })
|
| 43 |
+
// A preset (Settings → Recommended) may switch the provider — keep this select in sync.
|
| 44 |
+
onTtsEngineChange((id) => { engSel.value = id; renderInfo() })
|
| 45 |
|
| 46 |
renderInfo()
|
| 47 |
return { refresh: renderInfo }
|