heartcode 0.1.8 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/SKILL.md +45 -30
- package/bin/heartcode.js +56 -79
- package/package.json +1 -1
- package/prompts/agent.md +5 -4
- package/src/acp.js +7 -1
- package/src/agent.js +20 -51
- package/src/client.js +42 -40
- package/src/config.js +17 -4
- package/src/guided.js +2 -8
- package/src/headless.js +3 -0
- package/src/settings.html +120 -71
- package/src/settings.js +1 -1
package/src/settings.html
CHANGED
|
@@ -228,11 +228,15 @@
|
|
|
228
228
|
.olist .test button { flex: none; }
|
|
229
229
|
.olist .test.ok { color: var(--ok); }
|
|
230
230
|
.olist .test.warn { color: var(--accent); }
|
|
231
|
-
dialog
|
|
232
|
-
dialog
|
|
233
|
-
dialog
|
|
231
|
+
dialog { width: 70vw; max-width: none; max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); color: var(--text); padding: 22px 24px; }
|
|
232
|
+
dialog::backdrop { background: rgba(0, 0, 0, .55); }
|
|
233
|
+
dialog h2 { margin: 0 0 6px; font-family: var(--mono); font-size: 15px; overflow-wrap: anywhere; }
|
|
234
234
|
dialog#ms .opt:last-of-type { border-bottom: 0; }
|
|
235
|
-
|
|
235
|
+
#pr-list { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: rank; }
|
|
236
|
+
#pr-list li { counter-increment: rank; padding: 9px 12px; margin-bottom: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); cursor: grab; user-select: none; }
|
|
237
|
+
#pr-list li::before { content: counter(rank) ". ⋮⋮ "; color: var(--muted); }
|
|
238
|
+
#pr-list li.dragging { opacity: .4; }
|
|
239
|
+
dialog .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
236
240
|
.olist .test.failed { color: var(--err); }
|
|
237
241
|
.olist .test.testing, .olist .test.waiting { color: var(--accent); }
|
|
238
242
|
.olist .test button { padding: 0 7px; font-size: 11px; }
|
|
@@ -431,19 +435,6 @@
|
|
|
431
435
|
</div>
|
|
432
436
|
<div class="steps" id="steps"></div>
|
|
433
437
|
<div class="stack">
|
|
434
|
-
<div class="card" id="p-form" hidden>
|
|
435
|
-
<h2 id="p-title">Add a provider</h2>
|
|
436
|
-
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
437
|
-
<div class="presets" id="presets"></div>
|
|
438
|
-
<div class="row" style="margin-top:6px">
|
|
439
|
-
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
440
|
-
<div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
|
|
441
|
-
</div>
|
|
442
|
-
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
443
|
-
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
444
|
-
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
445
|
-
<div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
|
|
446
|
-
</div>
|
|
447
438
|
<div class="work" id="p-work" hidden>
|
|
448
439
|
<div class="card catalog">
|
|
449
440
|
<div class="cat-top">
|
|
@@ -565,20 +556,8 @@
|
|
|
565
556
|
|
|
566
557
|
<!-- MCP servers -->
|
|
567
558
|
<section class="page" id="page-mcp">
|
|
568
|
-
<div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div></div>
|
|
559
|
+
<div class="head"><div><h1>MCP servers</h1><p class="lead">Programs that give the model more tools: a browser, a database, your issue tracker. Off: HeartCode doesn't start it or offer it to the model.</p></div><button class="primary" id="c-open">+ Add a server</button></div>
|
|
569
560
|
<div class="stack">
|
|
570
|
-
<div class="card">
|
|
571
|
-
<h2>Add a server</h2>
|
|
572
|
-
<p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
|
|
573
|
-
<div class="row">
|
|
574
|
-
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
575
|
-
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
576
|
-
<label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
|
|
577
|
-
</div></div>
|
|
578
|
-
<div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
|
|
579
|
-
<button class="primary" id="c-add">Add server</button>
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
561
|
<div class="card" id="c-list"></div>
|
|
583
562
|
<details class="card" id="c-raw"><summary style="cursor:pointer;font-weight:600">Edit mcp.json directly</summary><p class="sub" style="margin-top:10px">For what the form doesn't have: environment variables (<span class="mono">"env"</span>), headers, and so on.</p><div id="c-file"></div></details>
|
|
584
563
|
</div>
|
|
@@ -617,6 +596,40 @@
|
|
|
617
596
|
<div class="actions"><button id="ms-cancel" type="button">Cancel</button><button class="primary" id="ms-save" type="button">Save</button></div>
|
|
618
597
|
</form>
|
|
619
598
|
</dialog>
|
|
599
|
+
<dialog id="p-form" aria-labelledby="p-title">
|
|
600
|
+
<h2 id="p-title">Add a provider</h2>
|
|
601
|
+
<p class="sub">Pick one to fill in its address, or type any OpenAI-compatible API below.</p>
|
|
602
|
+
<div class="presets" id="presets"></div>
|
|
603
|
+
<div class="row" style="margin-top:6px">
|
|
604
|
+
<div class="grow"><label class="f" for="p-name">Name</label><input type="text" id="p-name" placeholder="openrouter, groq, work…"></div>
|
|
605
|
+
<div class="grow" style="flex:2"><label class="f" for="p-url">Base URL</label><input type="text" id="p-url" placeholder="https://openrouter.ai/api/v1"></div>
|
|
606
|
+
</div>
|
|
607
|
+
<label class="f" for="p-key">API key <span class="muted" id="p-keynote" style="font-weight:normal"></span></label>
|
|
608
|
+
<input type="password" id="p-key" autocomplete="off" placeholder="paste it here (none for a server on this machine, like Ollama)">
|
|
609
|
+
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
610
|
+
<div class="actions"><button id="p-cancel">Cancel</button><button class="primary" id="p-save">Add provider</button></div>
|
|
611
|
+
</dialog>
|
|
612
|
+
<dialog id="c-form" aria-labelledby="c-title">
|
|
613
|
+
<h2 id="c-title">Add a server</h2>
|
|
614
|
+
<p class="sub">The command that starts it on this machine, or the URL of a remote one (a sign-in page opens if it needs one).</p>
|
|
615
|
+
<div class="row">
|
|
616
|
+
<div style="width:200px"><label class="f" for="c-name">Name</label><input type="text" id="c-name" placeholder="github"></div>
|
|
617
|
+
<div><label class="f">Kind</label><div class="seg" id="c-kind">
|
|
618
|
+
<label class="on"><input type="radio" name="c-kind" value="command" checked>Command</label><label><input type="radio" name="c-kind" value="url">URL</label>
|
|
619
|
+
</div></div>
|
|
620
|
+
<div class="grow" style="flex:3"><label class="f" for="c-what" id="c-what-label">Command</label><input type="text" id="c-what" placeholder="npx -y @modelcontextprotocol/server-github"></div>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="actions"><button id="c-cancel">Cancel</button><button class="primary" id="c-add">Add server</button></div>
|
|
623
|
+
</dialog>
|
|
624
|
+
<dialog id="k-form" aria-labelledby="k-title"></dialog>
|
|
625
|
+
<dialog id="pr" aria-labelledby="pr-title">
|
|
626
|
+
<form>
|
|
627
|
+
<h2 id="pr-title">Provider ranking</h2>
|
|
628
|
+
<p class="sub">Drag the providers into order, best first. Your models are then sorted by it.</p>
|
|
629
|
+
<ol id="pr-list"></ol>
|
|
630
|
+
<div class="actions"><button id="pr-cancel" type="button">Cancel</button><button class="primary" id="pr-save" type="button">Save</button></div>
|
|
631
|
+
</form>
|
|
632
|
+
</dialog>
|
|
620
633
|
<div id="toast" role="status"></div>
|
|
621
634
|
<div id="tip" role="tooltip"></div>
|
|
622
635
|
<script>
|
|
@@ -766,7 +779,7 @@ const DRAFTS = { prompts: {}, skills: {}, files: {} }
|
|
|
766
779
|
const SEL = { prompts: 'agent/system', skills: null, files: null }
|
|
767
780
|
function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
768
781
|
const all = groups.flatMap((g) => g.items)
|
|
769
|
-
if (!all.some((x) => x.id === SEL[kind])
|
|
782
|
+
if (!all.some((x) => x.id === SEL[kind])) SEL[kind] = all[0]?.id ?? null
|
|
770
783
|
// the list: built when its items change; otherwise each item is updated in place (the chosen
|
|
771
784
|
// one, a dot for unsaved changes, "yours"), so it stays where you scrolled it
|
|
772
785
|
const inside = (x) => [h('div', { className: 't' }, DRAFTS[kind][x.id] !== undefined ? h('span', { className: 'dot', title: 'not saved' }) : null, x.title, x.badge ?? null), x.sub ? h('div', { className: 'd', textContent: x.sub }) : null]
|
|
@@ -787,7 +800,7 @@ function masterDetail(kind, itemsEl, detailEl, groups, draw) {
|
|
|
787
800
|
}
|
|
788
801
|
itemsEl.closest('.md').classList.toggle('solo', !all.length) // nothing to list: no list column
|
|
789
802
|
itemsEl.closest('.md').classList.toggle('tabs', all.length > 0 && all.length <= 8) // a few: tabs on top
|
|
790
|
-
put(detailEl, draw(
|
|
803
|
+
put(detailEl, draw(all.find((x) => x.id === SEL[kind])))
|
|
791
804
|
itemsEl.closest('.md').classList.add('fill')
|
|
792
805
|
fillHeight()
|
|
793
806
|
}
|
|
@@ -879,10 +892,8 @@ const useModel = (provider, model) => act('/api/model', { provider, model }, `He
|
|
|
879
892
|
const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
|
|
880
893
|
|
|
881
894
|
let editing = null
|
|
882
|
-
let formOpen = false
|
|
883
895
|
function openForm(p = null) {
|
|
884
896
|
editing = p
|
|
885
|
-
formOpen = true
|
|
886
897
|
$('p-title').textContent = p ? `Edit ${p.name}` : 'Add a provider'
|
|
887
898
|
$('p-name').value = p?.name ?? ''
|
|
888
899
|
$('p-url').value = p?.baseUrl ?? ''
|
|
@@ -891,15 +902,11 @@ function openForm(p = null) {
|
|
|
891
902
|
$('p-keynote').textContent = p?.key ? `(now ${p.key})` : ''
|
|
892
903
|
$('p-save').textContent = p ? 'Save changes' : 'Add provider'
|
|
893
904
|
drawPresets()
|
|
894
|
-
|
|
895
|
-
$('p-form').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
905
|
+
$('p-form').showModal()
|
|
896
906
|
;(p ? $('p-url') : $('p-name')).focus()
|
|
897
907
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
formOpen = false
|
|
901
|
-
drawProviders(S)
|
|
902
|
-
}
|
|
908
|
+
const closeForm = () => $('p-form').close()
|
|
909
|
+
$('p-form').onclose = () => (editing = null)
|
|
903
910
|
$('p-open').onclick = () => openForm()
|
|
904
911
|
$('p-cancel').onclick = closeForm
|
|
905
912
|
$('p-save').onclick = async () => {
|
|
@@ -1183,17 +1190,12 @@ addEventListener('scroll', () => $('tip').classList.remove('show'), true)
|
|
|
1183
1190
|
function drawProviders(s) {
|
|
1184
1191
|
const order = s.order
|
|
1185
1192
|
const none = !s.providers.length
|
|
1186
|
-
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1187
|
-
// no provider yet, the form is open (the only way in: no other button)
|
|
1193
|
+
// the steps: always on the page (nothing jumps when one is done), ticked off as you go
|
|
1188
1194
|
put($('steps'), [
|
|
1189
1195
|
['Add a provider', 'the service with your models', s.providers.length ? 'done' : 'now'],
|
|
1190
1196
|
['Choose a model', 'click one in the list', order.length ? 'done' : s.providers.length ? 'now' : ''],
|
|
1191
1197
|
['Start HeartCode', 'heart, in your project', order.length ? 'now' : ''],
|
|
1192
1198
|
].map(([t, d, cls], i) => h('div', { className: 'step ' + cls }, h('span', { className: 'num', textContent: cls === 'done' ? '✓' : i + 1 }), h('div', {}, h('b', { textContent: t }), d))))
|
|
1193
|
-
const showForm = formOpen || none
|
|
1194
|
-
$('p-form').hidden = !showForm
|
|
1195
|
-
$('p-cancel').hidden = none // nothing to go back to
|
|
1196
|
-
$('p-open').hidden = showForm
|
|
1197
1199
|
$('p-work').hidden = none
|
|
1198
1200
|
fillHeight()
|
|
1199
1201
|
|
|
@@ -1241,9 +1243,9 @@ function drawProviders(s) {
|
|
|
1241
1243
|
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
1242
1244
|
h('div', { className: 'row', style: 'gap:8px' },
|
|
1243
1245
|
h('select', { id: 'r-on', style: 'flex:1', onchange: (e) => save(order, 'saved', { on: e.target.value }) },
|
|
1244
|
-
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model
|
|
1246
|
+
[['failure', 'at its first failure'], ['retries', 'after some tries of the same model (rate limits, outages)']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.on === v }))),
|
|
1245
1247
|
s.rotation.on === 'retries' ? h('input', { type: 'number', 'aria-label': 'tries', title: 'tries of the same model first', style: 'width:76px', min: 1, max: 20, value: s.rotation.retries, onchange: (e) => save(order, 'saved', { retries: Number(e.target.value) }) }) : null),
|
|
1246
|
-
h('div', { className: 'hint', textContent: { failure: '
|
|
1248
|
+
h('div', { className: 'hint', textContent: { failure: 'Any error moves on at once, to another provider\'s model first. It stops only when every model fails.', retries: 'A rate limit or outage: the same model is tried again (with a short wait) that many times first. Any other error moves on at once.' }[s.rotation.on] ?? 'Any error moves on at once, to another provider\'s model first.' }),
|
|
1247
1249
|
h('label', { className: 'f', for: 'r-next', textContent: 'A new message starts with' }),
|
|
1248
1250
|
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1249
1251
|
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
@@ -1254,7 +1256,7 @@ function drawProviders(s) {
|
|
|
1254
1256
|
// left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1255
1257
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1256
1258
|
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
|
|
1257
|
-
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + (MODELS[p.name]
|
|
1259
|
+
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + ((MODELS[p.name] ?? { loading: true }).loading ? 'loading' : '')), modelQuery.trim(), catSort])
|
|
1258
1260
|
// every provider's list at once: on when all are
|
|
1259
1261
|
const allOn = s.providers.length > 0 && s.providers.every((p) => p.list)
|
|
1260
1262
|
put($('lists-all'), [sw({ checked: allOn, 'aria-label': "load every provider's model list", onchange: (e) => act('/api/provider/list', { on: e.target.checked }, `every provider's model list ${e.target.checked ? 'on' : 'off'}`) }), 'Load all lists'])
|
|
@@ -1479,7 +1481,7 @@ const can = (t) => ({ tools: t.tested ? t.tested.mode === 'tools' : t.known?.too
|
|
|
1479
1481
|
// how: text (A to Z first), cheap (lowest first), else most first
|
|
1480
1482
|
const COLS = {
|
|
1481
1483
|
model: { label: 'Model', how: 'text', get: (t) => t.model },
|
|
1482
|
-
provider: { label: 'Provider', how: '
|
|
1484
|
+
provider: { label: 'Provider', how: 'rank', get: (t) => RANKED.get(t.provider) }, // by your ranking of the providers
|
|
1483
1485
|
context: { label: 'Context', get: (t) => t.tested?.context ?? t.known?.context ?? 0 },
|
|
1484
1486
|
abilities: { label: 'Abilities', get: (t) => Object.values(can(t)).filter(Boolean).length },
|
|
1485
1487
|
tools: { label: 'Tools', get: (t) => can(t).tools },
|
|
@@ -1496,6 +1498,44 @@ let SORT = (() => {
|
|
|
1496
1498
|
return []
|
|
1497
1499
|
}
|
|
1498
1500
|
})()
|
|
1501
|
+
// your ranking of the providers, best first, kept in this browser; a provider not ranked yet comes after, A to Z
|
|
1502
|
+
let RANK = (() => {
|
|
1503
|
+
try {
|
|
1504
|
+
return JSON.parse(localStorage.getItem('heart.rank') ?? '[]').filter((x) => typeof x === 'string')
|
|
1505
|
+
} catch {
|
|
1506
|
+
return []
|
|
1507
|
+
}
|
|
1508
|
+
})()
|
|
1509
|
+
const ranked = () => {
|
|
1510
|
+
const names = [...new Set(S.order.map((t) => t.provider))]
|
|
1511
|
+
return [...RANK.filter((n) => names.includes(n)), ...names.filter((n) => !RANK.includes(n)).sort(byName)]
|
|
1512
|
+
}
|
|
1513
|
+
// the ranking dialog: drag a provider to its place, then Save
|
|
1514
|
+
function openRanking() {
|
|
1515
|
+
const list = $('pr-list')
|
|
1516
|
+
let held = null
|
|
1517
|
+
put(list, ranked().map((name) => h('li', { draggable: true, textContent: name })))
|
|
1518
|
+
list.ondragstart = (e) => (held = e.target).classList.add('dragging')
|
|
1519
|
+
list.ondragend = () => held?.classList.remove('dragging')
|
|
1520
|
+
list.ondragover = (e) => {
|
|
1521
|
+
e.preventDefault()
|
|
1522
|
+
const over = e.target.closest('li')
|
|
1523
|
+
if (!held || !over || over === held) return
|
|
1524
|
+
const r = over.getBoundingClientRect()
|
|
1525
|
+
list.insertBefore(held, e.clientY > r.top + r.height / 2 ? over.nextSibling : over)
|
|
1526
|
+
}
|
|
1527
|
+
$('pr').showModal()
|
|
1528
|
+
}
|
|
1529
|
+
$('pr-cancel').onclick = () => $('pr').close()
|
|
1530
|
+
$('pr-save').onclick = () => {
|
|
1531
|
+
const list = [...$('pr-list').children].map((li) => li.textContent)
|
|
1532
|
+
RANK = [...list, ...RANK.filter((n) => !list.includes(n))] // providers not in your list now keep their place for later
|
|
1533
|
+
try {
|
|
1534
|
+
localStorage.setItem('heart.rank', JSON.stringify(RANK))
|
|
1535
|
+
} catch {}
|
|
1536
|
+
$('pr').close()
|
|
1537
|
+
setSort(SORT.some((x) => x.key === 'provider') ? SORT : [{ key: 'provider', dir: 'up' }, ...SORT]) // sorted by it now
|
|
1538
|
+
}
|
|
1499
1539
|
const firstDir = (key) => (COLS[key].how ? 'up' : 'down') // up: A to Z, low to high
|
|
1500
1540
|
function setSort(next) {
|
|
1501
1541
|
SORT = next
|
|
@@ -1511,7 +1551,9 @@ function clickSort(key) {
|
|
|
1511
1551
|
if (at.dir === firstDir(key)) return setSort(SORT.map((x) => (x === at ? { key, dir: at.dir === 'up' ? 'down' : 'up' } : x)))
|
|
1512
1552
|
setSort(SORT.filter((x) => x !== at))
|
|
1513
1553
|
}
|
|
1554
|
+
let RANKED = new Map() // provider → its place in the ranking, for one sort
|
|
1514
1555
|
function sorted(order) {
|
|
1556
|
+
RANKED = new Map(ranked().map((n, i) => [n, i]))
|
|
1515
1557
|
const cmp = (a, b, { key, dir }) => {
|
|
1516
1558
|
let x = COLS[key].get(a)
|
|
1517
1559
|
let y = COLS[key].get(b)
|
|
@@ -1523,7 +1565,7 @@ function sorted(order) {
|
|
|
1523
1565
|
}
|
|
1524
1566
|
return order.map((t, i) => ({ t, i })).sort((a, b) => SORT.reduce((r, s) => r || cmp(a.t, b.t, s), 0) || a.i - b.i).map((x) => x.t)
|
|
1525
1567
|
}
|
|
1526
|
-
const dirText = (key, dir) => (key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
|
|
1568
|
+
const dirText = (key, dir) => (key === 'provider' ? (dir === 'up' ? 'your ranking' : 'your ranking, reversed') : key === 'works' ? (dir === 'down' ? 'working first' : 'not working first') : COLS[key].how === 'text' ? (dir === 'up' ? 'A→Z' : 'Z→A') : COLS[key].how === 'cheap' ? (dir === 'up' ? 'cheapest first' : 'priciest first') : dir === 'down' ? 'most first' : 'least first')
|
|
1527
1569
|
// The models ticked on the Your models page ("provider/model"), to remove several at once; the last
|
|
1528
1570
|
// one ticked, so a Shift+click ticks the ones between
|
|
1529
1571
|
const PICKED = new Set()
|
|
@@ -1594,6 +1636,7 @@ function drawMine(s) {
|
|
|
1594
1636
|
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1595
1637
|
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1596
1638
|
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1639
|
+
order.some((t) => t.provider !== order[0].provider) ? h('button', { className: 'small', textContent: 'Provider ranking', title: 'drag your providers into order; the Provider column sorts by it', onclick: openRanking }) : null,
|
|
1597
1640
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1598
1641
|
SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
|
|
1599
1642
|
order.length ? h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', onclick: () => lookUp() }) : null,
|
|
@@ -1845,8 +1888,10 @@ for (const r of document.querySelectorAll('input[name="c-kind"]')) r.onchange =
|
|
|
1845
1888
|
}
|
|
1846
1889
|
$('c-add').onclick = async () => {
|
|
1847
1890
|
const r = await act('/api/mcp/add', { name: $('c-name').value, [mcpKind()]: $('c-what').value }, `added ${$('c-name').value.trim()}: it starts from the next message`)
|
|
1848
|
-
if (r) $('c-name').value = $('c-what').value = ''
|
|
1891
|
+
if (r) ($('c-name').value = $('c-what').value = ''), $('c-form').close()
|
|
1849
1892
|
}
|
|
1893
|
+
$('c-open').onclick = () => ($('c-form').showModal(), $('c-name').focus())
|
|
1894
|
+
$('c-cancel').onclick = () => $('c-form').close()
|
|
1850
1895
|
function drawMcp(s) {
|
|
1851
1896
|
put($('c-list'), h('h2', { textContent: 'Servers' }), h('p', { className: 'sub', textContent: 'Built in, and yours. Switch one off to keep it without using it.' }),
|
|
1852
1897
|
s.mcp.length ? s.mcp.map((m) => h('div', { className: 'opt' },
|
|
@@ -1873,7 +1918,27 @@ function parseSkill(text) {
|
|
|
1873
1918
|
}
|
|
1874
1919
|
const skillText = (k) => `---\n${k.description.trim() ? `description: ${k.description.trim().replace(/\n/g, ' ')}\n` : ''}use: ${k.use}\n${k.extra.map((l) => l + '\n').join('')}---\n\n${k.body.trim()}\n`
|
|
1875
1920
|
const NEW = { name: '', description: '', use: 'both', extra: [], body: '' }
|
|
1876
|
-
|
|
1921
|
+
// a new skill: the form in a dialog (what you typed is kept if you close it)
|
|
1922
|
+
function newSkill() {
|
|
1923
|
+
const f = skillForm('+new', NEW, { isNew: true })
|
|
1924
|
+
const first = !S.skills.length
|
|
1925
|
+
put($('k-form'), h('h2', { id: 'k-title', textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1926
|
+
first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
|
|
1927
|
+
f.el,
|
|
1928
|
+
h('div', { className: 'actions' },
|
|
1929
|
+
h('button', { textContent: 'Cancel', onclick: () => $('k-form').close() }),
|
|
1930
|
+
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1931
|
+
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1932
|
+
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1933
|
+
const was = SEL.skills
|
|
1934
|
+
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1935
|
+
if (await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`)) $('k-form').close()
|
|
1936
|
+
else SEL.skills = was
|
|
1937
|
+
} })))
|
|
1938
|
+
$('k-form').showModal()
|
|
1939
|
+
$('k-form').querySelector('input')?.focus()
|
|
1940
|
+
}
|
|
1941
|
+
$('k-new').onclick = newSkill
|
|
1877
1942
|
function skillForm(id, start, { isNew, readOnly }) {
|
|
1878
1943
|
const d = DRAFTS.skills[id] ?? { ...start, extra: [...start.extra] } // your unsaved changes, else as saved
|
|
1879
1944
|
const touch = () => (DRAFTS.skills[id] = d) // a change: kept (and dotted in the list) until it's saved
|
|
@@ -1891,27 +1956,11 @@ function skillForm(id, start, { isNew, readOnly }) {
|
|
|
1891
1956
|
] }
|
|
1892
1957
|
}
|
|
1893
1958
|
function drawSkills(s) {
|
|
1894
|
-
if (!s.skills.length) SEL.skills = '+new' // none yet: the form is the page (one way in, no empty card)
|
|
1895
|
-
$('k-new').hidden = SEL.skills === '+new'
|
|
1896
1959
|
const mine = s.skills.filter((k) => k.mine)
|
|
1897
1960
|
const theirs = s.skills.filter((k) => !k.mine)
|
|
1898
1961
|
const item = (k) => ({ id: k.name, title: '/' + k.name, sub: k.description, k })
|
|
1899
1962
|
masterDetail('skills', $('k-items'), $('k-detail'), [{ title: mine.length && theirs.length ? 'Yours' : '', items: mine.map(item) }, { title: 'From projects', items: theirs.map(item) }], (x) => {
|
|
1900
|
-
if (x
|
|
1901
|
-
const f = skillForm('+new', NEW, { isNew: true })
|
|
1902
|
-
const first = !s.skills.length
|
|
1903
|
-
return h('div', { className: 'card' }, h('h2', { textContent: first ? 'Make your first skill' : 'New skill' }),
|
|
1904
|
-
first ? h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Skills from Claude Code and others: /import in HeartCode.' }) : null,
|
|
1905
|
-
f.el,
|
|
1906
|
-
h('div', { className: 'actions' },
|
|
1907
|
-
h('button', { className: 'primary', textContent: 'Create skill', onclick: async () => {
|
|
1908
|
-
const slug = f.d.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1909
|
-
if (!f.d.body.trim()) return toast('Write the prompt first', true)
|
|
1910
|
-
SEL.skills = slug // the new one, chosen in the list once it's there
|
|
1911
|
-
if (!(await saveItem('skills', '+new', '/api/skill', { name: f.d.name, text: skillText(f.d) }, `created /${slug}`))) SEL.skills = '+new'
|
|
1912
|
-
} }),
|
|
1913
|
-
first ? null : h('button', { className: 'ghost', textContent: 'Cancel', onclick: () => (saved('skills', '+new'), (SEL.skills = null), drawSkills(S)) })))
|
|
1914
|
-
}
|
|
1963
|
+
if (!x) return h('div', { className: 'card' }, h('h2', { textContent: 'No skills yet' }), h('p', { className: 'sub', textContent: 'A skill is a prompt you use again and again, like "review this" or "write the release notes". Make one with + New skill, or bring them from Claude Code and others with /import in HeartCode.' }))
|
|
1915
1964
|
const k = x.k
|
|
1916
1965
|
const f = skillForm(k.name, parseSkill(k.text), { readOnly: !k.mine })
|
|
1917
1966
|
return h('div', { className: 'card' },
|
package/src/settings.js
CHANGED
|
@@ -427,7 +427,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
427
427
|
if (!p) throw new Error(`no provider "${name}"`)
|
|
428
428
|
if (p.list === false) return { models: [], details: {}, off: true }
|
|
429
429
|
const lists = readJson(LISTS, {})
|
|
430
|
-
let saved = lists[name]?.baseUrl === p.baseUrl ? lists[name] : null
|
|
430
|
+
let saved = lists[name]?.baseUrl === p.baseUrl && lists[name].models?.length ? lists[name] : null // an empty one: asked again
|
|
431
431
|
if (!saved) {
|
|
432
432
|
const entries = await new Client({ baseUrl: p.baseUrl, apiKey: p.apiKey }).modelEntries()
|
|
433
433
|
const num = (...xs) => xs.map(Number).find((x) => x > 0)
|