tiny-army / web /skillForgePanel.js
polats's picture
Skill Forge: optional "show thinking" for coding models
6e155d8
// Skill Forge — the Sandbox surface that uses the Coding Model to author a combat
// skill for a chosen hero. Pick a recruited persona, describe the skill you want, and
// the coding model (Nemotron via NIM, or Mellum2 via ZeroGPU — see Settings → Coding
// Model) writes a self-contained skill definition tailored to that hero. For now it
// just generates and shows the skill; wiring it into the battle engine comes later.
import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
import { listPersonas, getPersona, onRosterChange } from '/web/personaStore.js'
import { stripThink, stripThinkFinal } from '/web/personaPrompts.js'
function el(tag, props = {}, kids = []) {
const n = document.createElement(tag)
for (const [k, v] of Object.entries(props)) {
if (k === 'class') n.className = v
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
else if (v != null) n.setAttribute(k, v)
}
for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
return n
}
const SYSTEM = [
'You are the Skill Forge for a fantasy auto-battler. You author ONE combat skill for a',
'specific hero, tailored to their class, story and personality. Respond with exactly:',
'a one-line skill name, a one-sentence flavour description, then a fenced ```json block',
'with fields {name, type:"active"|"passive", target:"self"|"ally"|"enemy"|"area",',
'cooldown:<int seconds>, effect:"<short mechanical description>"}.',
'Keep it concise and balanced. Output only the skill — no preamble, no commentary.',
].join(' ')
function personaBlock(p) {
if (!p) return ''
return [
`Hero: ${p.name || 'Unnamed'}${p.unitClass ? ` — ${p.unitClass}` : ''}`,
p.about ? `About: ${p.about}` : '',
p.personality ? `Personality: ${p.personality}` : '',
p.specialty ? `Specialty: ${p.specialty}` : '',
].filter(Boolean).join('\n')
}
export function mountSkillForgePanel(host) {
const sel = el('select', { class: 'persona-input skillforge-hero' })
const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' })
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill')
const status = el('div', { class: 'persona-status' })
const out = el('pre', { class: 'persona-think skillforge-out' })
const empty = el('div', { class: 'persona-roster-empty' },
'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
// "Show thinking" reveals the reasoning models' <think> trace (Nemotron, BLS) in the debug
// panel below; off by default so forging stays fast/clean. (Mellum2 has no reasoning.)
const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' })
const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' },
[thinkChk, ' show model thinking'])
const dbgEl = el('pre', { class: 'persona-think' })
const copyBtn = el('button', { class: 'persona-copy', type: 'button',
onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy')
const dbgWrap = el('details', { class: 'persona-think-wrap' },
[el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl])
dbgWrap.style.display = thinkChk.checked ? '' : 'none'
const controls = el('aside', { class: 'persona-controls skillforge' }, [
el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, 'Skill Forge'), el('span')]),
el('label', { class: 'persona-label' }, 'Hero'), sel,
empty,
el('label', { class: 'persona-label' }, 'Skill request'), req,
thinkLabel,
el('div', { class: 'persona-prompt-actions' }, [btn]),
status,
el('label', { class: 'persona-label' }, 'Forged skill'), out,
dbgWrap,
])
host.append(controls)
thinkChk.addEventListener('change', () => { dbgWrap.style.display = thinkChk.checked ? '' : 'none' })
function refreshHeroes() {
const people = listPersonas()
const prev = sel.value
sel.replaceChildren(...people.map((p) => el('option', { value: p.id }, p.name || 'Unnamed hero')))
if (people.some((p) => p.id === prev)) sel.value = prev
const none = people.length === 0
empty.style.display = none ? '' : 'none'
sel.style.display = none ? 'none' : ''
btn.disabled = none
}
function refreshStatus() {
if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}`
}
let running = false
async function forge() {
if (running) return
const p = getPersona(sel.value)
if (!p) { status.textContent = 'Pick a hero first.'; return }
const ask = req.value.trim()
if (!ask) { status.textContent = 'Describe the skill you want.'; return }
running = true; status.dataset.busy = '1'; btn.disabled = true
out.textContent = ''; dbgEl.textContent = ''
status.textContent = `Forging with ${currentCodingModel().label}…`
const user = `${personaBlock(p)}\n\nSkill to create: ${ask}`
const showThink = thinkChk.checked
let raw = ''
try {
const { stats } = await streamCoding(SYSTEM, user, {
maxTokens: 512,
temperature: 0.6,
think: showThink,
// Reasoning streams inside <think>…</think>; show the raw trace in the debug panel and
// the stripped answer in the output (same split the persona panel uses).
onToken: (t) => {
raw += t
out.textContent = stripThink(raw)
if (showThink) { dbgEl.textContent = raw; dbgWrap.open = true; dbgEl.scrollTop = dbgEl.scrollHeight }
},
})
out.textContent = stripThinkFinal(raw)
const tps = stats && stats.tokPerSec ? ` · ${stats.tokPerSec} tok/s` : ''
status.textContent = `Done${tps}.`
} catch (e) {
status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
} finally {
running = false; delete status.dataset.busy; btn.disabled = listPersonas().length === 0
}
}
btn.addEventListener('click', forge)
onRosterChange(refreshHeroes)
onCodingModelChange(refreshStatus)
refreshHeroes(); refreshStatus()
return { refresh: refreshHeroes }
}