File size: 6,279 Bytes
1f1908e
 
 
 
 
 
 
6e155d8
1f1908e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e155d8
 
 
 
 
 
 
 
 
 
 
 
1f1908e
 
 
 
 
6e155d8
1f1908e
 
 
6e155d8
1f1908e
 
 
6e155d8
 
1f1908e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e155d8
1f1908e
 
6e155d8
 
1f1908e
 
 
 
6e155d8
 
 
 
 
 
 
 
1f1908e
6e155d8
1f1908e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// 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 }
}