Spaces:
Running
Running
| // 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 } | |
| } | |