Spaces:
Running
Running
Persona: simplify prompt to name/about/quote/voice; editable in Settings
Browse files- Drop the specialty/personality/vibe tags from the generation prompt and the
hero panel — only name, about, quote and voice design remain.
- New Settings → "Persona Prompt" section: view and edit the system prompt that
writes each hero. Saved locally (tinyarmy.personaPrompt) and used on the next
"Recruit hero"; Reset restores the built-in default.
- personaPrompts.js exposes PERSONA_SYSTEM_DEFAULT + get/set/reset/isCustom;
personaPanel.js calls getPersonaSystem() so an override wins.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +4 -11
- web/personaPromptBar.js +51 -0
- web/personaPrompts.js +22 -7
- web/settingsPanel.js +4 -0
- web/shell/persona.css +11 -0
web/personaPanel.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 7 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 8 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 9 |
-
import {
|
| 10 |
import { createVoiceWav, cloneVoiceWav, playWav, stopPreview } from '/web/tts.js'
|
| 11 |
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
|
| 12 |
|
|
@@ -140,7 +140,6 @@ export function mountPersonaPanel(host) {
|
|
| 140 |
const rosterEl = el('div', { class: 'persona-roster' })
|
| 141 |
|
| 142 |
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 143 |
-
const tagsEl = el('div', { class: 'persona-tags' })
|
| 144 |
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
| 145 |
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
|
| 146 |
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
|
@@ -165,7 +164,7 @@ export function mountPersonaPanel(host) {
|
|
| 165 |
])
|
| 166 |
const emptyEl = el('div', { class: 'persona-empty' }, 'Recruit a hero, or pick one from the barracks.')
|
| 167 |
const bodyEl = el('div', { class: 'persona-body' }, [
|
| 168 |
-
nameEl,
|
| 169 |
secHead('About'), aboutEl,
|
| 170 |
secHead('Quote', playBtn), quoteEl,
|
| 171 |
secHead('Voice design'), voiceEl,
|
|
@@ -246,17 +245,11 @@ export function mountPersonaPanel(host) {
|
|
| 246 |
editable(quoteEl, 'quote', { single: true })
|
| 247 |
editable(voiceEl, 'voice')
|
| 248 |
|
| 249 |
-
function setTags(p) {
|
| 250 |
-
tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
|
| 251 |
-
.map((t) => el('span', { class: 'persona-tag' }, t)))
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
async function showPersona(p, opts = {}) {
|
| 255 |
stopVoice() // picking another hero cuts the current voice
|
| 256 |
lastPersona = { ...p }
|
| 257 |
savedId = opts.savedId || null
|
| 258 |
nameEl.textContent = p.name || ''
|
| 259 |
-
setTags(p)
|
| 260 |
aboutEl.textContent = p.about || ''
|
| 261 |
quoteEl.textContent = p.quote || ''
|
| 262 |
voiceEl.textContent = p.voice || ''
|
|
@@ -357,7 +350,7 @@ export function mountPersonaPanel(host) {
|
|
| 357 |
if (busy) return
|
| 358 |
busy = true; btn.disabled = true; refreshVisibility()
|
| 359 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 360 |
-
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 361 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 362 |
lastPersona = null; savedId = null; hasVoice = false
|
| 363 |
stopVoice(); updateVoiceUI()
|
|
@@ -367,7 +360,7 @@ export function mountPersonaPanel(host) {
|
|
| 367 |
status.textContent = `loading ${currentModel().label}…`
|
| 368 |
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 369 |
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 370 |
-
await streamChat(
|
| 371 |
maxTokens: MAX_TOKENS,
|
| 372 |
onToken: (piece) => {
|
| 373 |
acc += piece
|
|
|
|
| 6 |
import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
|
| 7 |
import { extractLivePersona } from '/web/personaStream.js'
|
| 8 |
import { parsePersonaJson } from '/web/personaParse.js'
|
| 9 |
+
import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
|
| 10 |
import { createVoiceWav, cloneVoiceWav, playWav, stopPreview } from '/web/tts.js'
|
| 11 |
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
|
| 12 |
|
|
|
|
| 140 |
const rosterEl = el('div', { class: 'persona-roster' })
|
| 141 |
|
| 142 |
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
|
|
|
| 143 |
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
| 144 |
const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
|
| 145 |
const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
|
|
|
|
| 164 |
])
|
| 165 |
const emptyEl = el('div', { class: 'persona-empty' }, 'Recruit a hero, or pick one from the barracks.')
|
| 166 |
const bodyEl = el('div', { class: 'persona-body' }, [
|
| 167 |
+
nameEl,
|
| 168 |
secHead('About'), aboutEl,
|
| 169 |
secHead('Quote', playBtn), quoteEl,
|
| 170 |
secHead('Voice design'), voiceEl,
|
|
|
|
| 245 |
editable(quoteEl, 'quote', { single: true })
|
| 246 |
editable(voiceEl, 'voice')
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
async function showPersona(p, opts = {}) {
|
| 249 |
stopVoice() // picking another hero cuts the current voice
|
| 250 |
lastPersona = { ...p }
|
| 251 |
savedId = opts.savedId || null
|
| 252 |
nameEl.textContent = p.name || ''
|
|
|
|
| 253 |
aboutEl.textContent = p.about || ''
|
| 254 |
quoteEl.textContent = p.quote || ''
|
| 255 |
voiceEl.textContent = p.voice || ''
|
|
|
|
| 350 |
if (busy) return
|
| 351 |
busy = true; btn.disabled = true; refreshVisibility()
|
| 352 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 353 |
+
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 354 |
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 355 |
lastPersona = null; savedId = null; hasVoice = false
|
| 356 |
stopVoice(); updateVoiceUI()
|
|
|
|
| 360 |
status.textContent = `loading ${currentModel().label}…`
|
| 361 |
await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
|
| 362 |
status.textContent = `writing on your device with ${currentModel().label}…`
|
| 363 |
+
await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
|
| 364 |
maxTokens: MAX_TOKENS,
|
| 365 |
onToken: (piece) => {
|
| 366 |
acc += piece
|
web/personaPromptBar.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Persona-prompt viewer/editor for the Settings page. Shows the system prompt that
|
| 2 |
+
// writes each hero (name / about / quote / voice design) and lets you edit it. A saved
|
| 3 |
+
// edit is stored locally (personaPrompts.js → setPersonaSystem) and used by the persona
|
| 4 |
+
// panel on the next "Recruit hero"; Reset restores the built-in default.
|
| 5 |
+
import {
|
| 6 |
+
PERSONA_SYSTEM_DEFAULT, getPersonaSystem, setPersonaSystem, resetPersonaSystem, isPersonaSystemCustom,
|
| 7 |
+
} from '/web/personaPrompts.js'
|
| 8 |
+
|
| 9 |
+
function el(tag, props = {}, kids = []) {
|
| 10 |
+
const n = document.createElement(tag)
|
| 11 |
+
for (const [k, v] of Object.entries(props)) {
|
| 12 |
+
if (k === 'class') n.className = v
|
| 13 |
+
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 14 |
+
else if (v != null) n.setAttribute(k, v)
|
| 15 |
+
}
|
| 16 |
+
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
|
| 17 |
+
return n
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function mountPersonaPromptBar(host) {
|
| 21 |
+
const ta = el('textarea', { class: 'persona-prompt-edit', rows: 12, spellcheck: 'false' })
|
| 22 |
+
const saveBtn = el('button', { class: 'persona-go persona-prompt-save', type: 'button' }, 'Save')
|
| 23 |
+
const resetBtn = el('button', { class: 'persona-ico persona-prompt-reset', type: 'button' }, 'Reset to default')
|
| 24 |
+
const note = el('div', { class: 'persona-status persona-prompt-note' })
|
| 25 |
+
|
| 26 |
+
ta.value = getPersonaSystem()
|
| 27 |
+
|
| 28 |
+
function refreshNote() {
|
| 29 |
+
note.textContent = isPersonaSystemCustom() ? 'Using your custom prompt.' : 'Using the built-in default.'
|
| 30 |
+
}
|
| 31 |
+
refreshNote()
|
| 32 |
+
|
| 33 |
+
saveBtn.addEventListener('click', () => {
|
| 34 |
+
setPersonaSystem(ta.value)
|
| 35 |
+
ta.value = getPersonaSystem() // reflect (a default-equal edit clears the override)
|
| 36 |
+
refreshNote()
|
| 37 |
+
saveBtn.textContent = '✓ Saved'; setTimeout(() => { saveBtn.textContent = 'Save' }, 1400)
|
| 38 |
+
})
|
| 39 |
+
resetBtn.addEventListener('click', () => {
|
| 40 |
+
resetPersonaSystem()
|
| 41 |
+
ta.value = PERSONA_SYSTEM_DEFAULT
|
| 42 |
+
refreshNote()
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
host.append(el('div', { class: 'persona-prompt-bar' }, [
|
| 46 |
+
ta,
|
| 47 |
+
el('div', { class: 'persona-prompt-actions' }, [saveBtn, resetBtn]),
|
| 48 |
+
note,
|
| 49 |
+
]))
|
| 50 |
+
return { refresh: () => { ta.value = getPersonaSystem(); refreshNote() } }
|
| 51 |
+
}
|
web/personaPrompts.js
CHANGED
|
@@ -1,24 +1,39 @@
|
|
| 1 |
// Tiny-Army persona + war-diary prompts (mirrors the Python prompts.py for the
|
| 2 |
// in-browser path). War-legend tone, not woid's.
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
'You invent tiny heroes for a fantasy auto-battler called Tiny Army, where every ' +
|
| 6 |
'fighter writes its own legend. Given a class and an optional seed, return ONE JSON ' +
|
| 7 |
'object and NOTHING else, with exactly these keys:\n' +
|
| 8 |
' "name": a short evocative hero name (2-4 words),\n' +
|
| 9 |
' "about": 1-2 short sentences of backstory (about 25 words) in a heroic, wry war-legend tone,\n' +
|
| 10 |
-
' "
|
| 11 |
-
'
|
| 12 |
-
' "vibe": a 1-3 word vibe,\n' +
|
| 13 |
' "voice": one sentence describing how THIS hero sounds for a text-to-speech voice — ' +
|
| 14 |
'pick a gender, age, pitch, accent, texture, pace and emotion that FIT their class and ' +
|
| 15 |
'personality, and make it DISTINCT from a generic gruff soldier. Vary it widely between ' +
|
| 16 |
'heroes — e.g. a bright quick-tongued young woman, a wheezing ancient sage, a velvet-smooth ' +
|
| 17 |
-
'rogue, a booming jolly giant, a cold precise duelist, a sing-song forest spirit
|
| 18 |
-
' "quote": one short punchy line they say aloud — a battle-cry or wry remark, ' +
|
| 19 |
-
'first person, under 15 words.\n' +
|
| 20 |
'Output strictly valid JSON. No preamble, no code fences, no commentary.'
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
export const DIARY_SYSTEM =
|
| 23 |
'You are a tiny hero in the auto-battler Tiny Army, writing a short first-person ' +
|
| 24 |
'war-diary entry. Given your name and traits, write just 1-2 vivid sentences (about ' +
|
|
|
|
| 1 |
// Tiny-Army persona + war-diary prompts (mirrors the Python prompts.py for the
|
| 2 |
// in-browser path). War-legend tone, not woid's.
|
| 3 |
|
| 4 |
+
// The default persona system prompt. Editable at runtime from the Settings page —
|
| 5 |
+
// see getPersonaSystem()/setPersonaSystem() below; personaPanel.js calls
|
| 6 |
+
// getPersonaSystem() so a saved override wins.
|
| 7 |
+
export const PERSONA_SYSTEM_DEFAULT =
|
| 8 |
'You invent tiny heroes for a fantasy auto-battler called Tiny Army, where every ' +
|
| 9 |
'fighter writes its own legend. Given a class and an optional seed, return ONE JSON ' +
|
| 10 |
'object and NOTHING else, with exactly these keys:\n' +
|
| 11 |
' "name": a short evocative hero name (2-4 words),\n' +
|
| 12 |
' "about": 1-2 short sentences of backstory (about 25 words) in a heroic, wry war-legend tone,\n' +
|
| 13 |
+
' "quote": one short punchy line they say aloud — a battle-cry or wry remark, ' +
|
| 14 |
+
'first person, under 15 words,\n' +
|
|
|
|
| 15 |
' "voice": one sentence describing how THIS hero sounds for a text-to-speech voice — ' +
|
| 16 |
'pick a gender, age, pitch, accent, texture, pace and emotion that FIT their class and ' +
|
| 17 |
'personality, and make it DISTINCT from a generic gruff soldier. Vary it widely between ' +
|
| 18 |
'heroes — e.g. a bright quick-tongued young woman, a wheezing ancient sage, a velvet-smooth ' +
|
| 19 |
+
'rogue, a booming jolly giant, a cold precise duelist, a sing-song forest spirit.\n' +
|
|
|
|
|
|
|
| 20 |
'Output strictly valid JSON. No preamble, no code fences, no commentary.'
|
| 21 |
|
| 22 |
+
// Runtime-editable persona prompt (Settings page). A non-empty localStorage override
|
| 23 |
+
// replaces the default; clearing it falls back to PERSONA_SYSTEM_DEFAULT.
|
| 24 |
+
const PERSONA_PROMPT_KEY = 'tinyarmy.personaPrompt'
|
| 25 |
+
export function getPersonaSystem() {
|
| 26 |
+
try { return localStorage.getItem(PERSONA_PROMPT_KEY) || PERSONA_SYSTEM_DEFAULT } catch { return PERSONA_SYSTEM_DEFAULT }
|
| 27 |
+
}
|
| 28 |
+
export function setPersonaSystem(text) {
|
| 29 |
+
try {
|
| 30 |
+
if (text && text.trim() && text.trim() !== PERSONA_SYSTEM_DEFAULT.trim()) localStorage.setItem(PERSONA_PROMPT_KEY, text)
|
| 31 |
+
else localStorage.removeItem(PERSONA_PROMPT_KEY)
|
| 32 |
+
} catch { /* storage blocked — just use the default */ }
|
| 33 |
+
}
|
| 34 |
+
export function resetPersonaSystem() { try { localStorage.removeItem(PERSONA_PROMPT_KEY) } catch { /* ignore */ } }
|
| 35 |
+
export function isPersonaSystemCustom() { try { return !!localStorage.getItem(PERSONA_PROMPT_KEY) } catch { return false } }
|
| 36 |
+
|
| 37 |
export const DIARY_SYSTEM =
|
| 38 |
'You are a tiny hero in the auto-battler Tiny Army, writing a short first-person ' +
|
| 39 |
'war-diary entry. Given your name and traits, write just 1-2 vivid sentences (about ' +
|
web/settingsPanel.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
// just won't appear (graceful no-op) and the app still runs on defaults.
|
| 9 |
import { mountModelBar } from '/web/modelBar.js'
|
| 10 |
import { mountTtsBar } from '/web/ttsBar.js'
|
|
|
|
| 11 |
|
| 12 |
function el(tag, props = {}, kids = []) {
|
| 13 |
const n = document.createElement(tag)
|
|
@@ -44,6 +45,9 @@ export function mountSettingsPanel() {
|
|
| 44 |
injectSection(sample, 'tac-voice-settings', 'Voice',
|
| 45 |
'How war diaries are read aloud. Kokoro/Kitten run on your device; Qwen3-TTS ' +
|
| 46 |
'designs a voice in the cloud.', mountTtsBar)
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
|
| 49 |
tryInject()
|
|
|
|
| 8 |
// just won't appear (graceful no-op) and the app still runs on defaults.
|
| 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)
|
|
|
|
| 45 |
injectSection(sample, 'tac-voice-settings', 'Voice',
|
| 46 |
'How war diaries are read aloud. Kokoro/Kitten run on your device; Qwen3-TTS ' +
|
| 47 |
'designs a voice in the cloud.', mountTtsBar)
|
| 48 |
+
injectSection(sample, 'tac-persona-prompt-settings', 'Persona Prompt',
|
| 49 |
+
'The system prompt that writes each hero (name, about, quote and voice design). ' +
|
| 50 |
+
'Edit it to change their style; Save uses it on the next “Recruit hero”.', mountPersonaPromptBar)
|
| 51 |
}
|
| 52 |
new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true })
|
| 53 |
tryInject()
|
web/shell/persona.css
CHANGED
|
@@ -250,6 +250,17 @@
|
|
| 250 |
.tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
|
| 251 |
.tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
/* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
|
| 254 |
.ctl-collapse > summary { display: none; }
|
| 255 |
|
|
|
|
| 250 |
.tac-set-section .model-bar { border-bottom: 0; padding-bottom: 0; }
|
| 251 |
.tac-set-intro { font-size: 14px; line-height: 1.5; opacity: .75; margin: 2px 0 14px; }
|
| 252 |
|
| 253 |
+
/* Persona-prompt editor (Settings → Persona Prompt). */
|
| 254 |
+
.persona-prompt-bar { display: flex; flex-direction: column; gap: 10px; }
|
| 255 |
+
.persona-prompt-edit {
|
| 256 |
+
width: 100%; font-family: var(--p-mono); font-size: 12px; line-height: 1.55;
|
| 257 |
+
color: var(--p-ink); background: var(--p-card); border: 1.5px solid var(--p-ink);
|
| 258 |
+
border-radius: 0; padding: 10px 12px; resize: vertical; min-height: 200px;
|
| 259 |
+
}
|
| 260 |
+
.persona-prompt-edit:focus { outline: none; box-shadow: 0 0 0 1.5px var(--p-transmit); }
|
| 261 |
+
.persona-prompt-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 262 |
+
.persona-prompt-save { margin-top: 0 !important; }
|
| 263 |
+
|
| 264 |
/* Collapsible control sections (model / voice). Desktop: no toggle, always shown. */
|
| 265 |
.ctl-collapse > summary { display: none; }
|
| 266 |
|