polats Claude Opus 4.8 (1M context) commited on
Commit
575fb61
·
1 Parent(s): c531198

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 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 { PERSONA_SYSTEM, 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,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, tagsEl,
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 = ''; tagsEl.replaceChildren()
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(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
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
- export const PERSONA_SYSTEM =
 
 
 
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
- ' "specialty": a 1-3 word combat specialty,\n' +
11
- ' "personality": a 1-3 word personality tag,\n' +
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,\n' +
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