heartcode 0.1.7 → 0.1.9
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/SKILL.md +5 -1
- package/package.json +1 -1
- package/prompts/agent.md +5 -4
- package/src/agent.js +5 -2
- package/src/client.js +40 -6
- package/src/config.js +5 -2
- package/src/settings.html +508 -148
- package/src/settings.js +223 -33
package/src/settings.html
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
nav a.on { background: var(--brand-soft); color: var(--text); font-weight: 600; }
|
|
36
36
|
nav a .ic { width: 18px; text-align: center; opacity: .8; }
|
|
37
37
|
nav a .count { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: normal; }
|
|
38
|
+
.using:empty { visibility: hidden; } /* until your settings are in */
|
|
38
39
|
.using { margin: auto 4px 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; font-size: 12.5px; background: var(--panel2); }
|
|
39
40
|
.using .k { color: var(--faint); display: block; }
|
|
40
41
|
.using b { display: block; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 2px 0; }
|
|
@@ -150,9 +151,15 @@
|
|
|
150
151
|
both fill the screen's height and scroll inside */
|
|
151
152
|
.work { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } /* the catalog and your models, half and half */
|
|
152
153
|
.work > .card { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
153
|
-
.cat-top { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex;
|
|
154
|
-
.cat-
|
|
155
|
-
.cat-
|
|
154
|
+
.cat-top { padding: 14px 16px 12px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
|
|
155
|
+
.cat-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
156
|
+
.cat-row input { flex: 1; min-width: 160px; }
|
|
157
|
+
.cat-row select { flex: none; width: auto; padding-right: 30px; }
|
|
158
|
+
.cat-row button { flex: none; height: 28px; }
|
|
159
|
+
.cat-row button.ghost { padding: 3px 8px; }
|
|
160
|
+
.cat-row button.ghost:hover:not(:disabled) { color: var(--text); border-color: transparent; }
|
|
161
|
+
.cat-group { display: flex; align-items: center; gap: 6px; }
|
|
162
|
+
.cat-group.end { margin-left: auto; }
|
|
156
163
|
.prov-h { cursor: pointer; }
|
|
157
164
|
.prov-h .fold { border: 0; background: none; color: var(--muted); padding: 2px 4px; font-size: 16px; line-height: 1; transition: transform .15s; align-self: flex-start; margin-top: 1px; }
|
|
158
165
|
.prov-h .fold:hover { color: var(--text); }
|
|
@@ -169,13 +176,18 @@
|
|
|
169
176
|
.mrow:hover { background: var(--hover); }
|
|
170
177
|
.mrow.flash { animation: flash 1.6s ease-out; }
|
|
171
178
|
@keyframes flash { from { background: var(--brand-soft); box-shadow: inset 3px 0 0 var(--brand); } to { background: none; box-shadow: none; } }
|
|
172
|
-
.mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow-
|
|
179
|
+
.mrow .id { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
180
|
+
.rows > .mrow, .rows > .mwrap { height: 56px; } /* one height: a long list only draws what's in view */
|
|
181
|
+
.mwrap .mrow { height: 100%; }
|
|
182
|
+
.mrow .can { display: block; margin-top: 2px; color: var(--muted); font-family: system-ui, sans-serif; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; }
|
|
173
183
|
.mrow .add { color: var(--accent); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
|
174
184
|
.mrow.taken .id { color: var(--muted); }
|
|
175
185
|
.mwrap { display: flex; break-inside: avoid; border-bottom: 1px solid var(--line); }
|
|
176
186
|
.mwrap .mrow { border-bottom: 0; }
|
|
177
187
|
.mwrap .forget { border: 0; background: none; color: var(--faint); padding: 0 12px; }
|
|
178
188
|
.mwrap .forget:hover { color: var(--err); }
|
|
189
|
+
.cat-top .listsw { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); white-space: nowrap; margin-right: 6px; }
|
|
190
|
+
.cat-top .listsw .sw { margin-top: 0; }
|
|
179
191
|
.prov-h .listsw { margin-top: 8px; width: fit-content; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
|
180
192
|
.prov-h .listsw .sw { margin-top: 0; }
|
|
181
193
|
.log-opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; }
|
|
@@ -211,7 +223,7 @@
|
|
|
211
223
|
.byname input { height: 32px; font-family: var(--mono); font-size: 12.5px; }
|
|
212
224
|
/* one line: what models.dev says (cut short, all of it on hover), its buttons on the right */
|
|
213
225
|
.olist .test { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
214
|
-
.olist .test .msg { flex: 1; min-width: 0;
|
|
226
|
+
.olist .test .msg { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }
|
|
215
227
|
.olist .test .spin { flex: none; }
|
|
216
228
|
.olist .test button { flex: none; }
|
|
217
229
|
.olist .test.ok { color: var(--ok); }
|
|
@@ -231,11 +243,12 @@
|
|
|
231
243
|
.mine-title h2 { margin: 0 0 6px; flex: 1; white-space: nowrap; }
|
|
232
244
|
.mine-title button { white-space: nowrap; }
|
|
233
245
|
/* your models as a table: a header, then one line a model */
|
|
234
|
-
.ohead, .olist li.in { display: grid; grid-template-columns: 14px
|
|
246
|
+
.ohead, .olist li.in { display: grid; grid-template-columns: 14px 44px minmax(0, 1.6fr) minmax(0, 0.6fr) minmax(0, 1.7fr) 26px; align-items: center; column-gap: 8px; padding: 7px 16px; }
|
|
235
247
|
.ohead { font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding-top: 8px; padding-bottom: 8px; }
|
|
236
248
|
.olist { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
|
|
237
249
|
.olist li { border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; }
|
|
238
|
-
.olist li.in
|
|
250
|
+
.olist li.in.use { background: var(--brand-soft); }
|
|
251
|
+
.olist li.in.lifted { background: var(--panel); }
|
|
239
252
|
.olist li.next { border-bottom: 0; background: none; color: var(--faint); font-size: 12.5px; text-align: center; padding: 14px 10px; }
|
|
240
253
|
.olist li.in { cursor: grab; user-select: none; transition: transform .16s ease, box-shadow .16s ease; position: relative; }
|
|
241
254
|
.olist.sorting li.in { transition: transform .16s ease; }
|
|
@@ -249,8 +262,24 @@
|
|
|
249
262
|
.mt-top .chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--brand); background: var(--brand-soft); border-radius: 999px; padding: 1px 4px 1px 9px; margin: 2px 4px 2px 0; color: var(--text); font-size: 12.5px; }
|
|
250
263
|
.mt-top .chip button { border: 0; background: none; padding: 0 4px; font-size: 12px; color: var(--muted); }
|
|
251
264
|
.mt-scroll { overflow-x: auto; }
|
|
252
|
-
.mt { min-width:
|
|
253
|
-
.mt-h, .mtable
|
|
265
|
+
.mt { min-width: 1116px; }
|
|
266
|
+
.mt-h input[type=checkbox], .mtable input[type=checkbox] { width: 15px; height: 15px; margin: 0; accent-color: var(--brand); cursor: pointer; }
|
|
267
|
+
.mtable li.picked { background: var(--brand-soft); }
|
|
268
|
+
.mt-outcomes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--muted); }
|
|
269
|
+
.mt-outcomes > span:first-child { margin-right: 4px; }
|
|
270
|
+
/* a test result: what it was and how many (a click selects them), and ↻ to test them again */
|
|
271
|
+
.pill { display: inline-flex; align-items: stretch; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); overflow: hidden; font-size: 12px; line-height: 1; }
|
|
272
|
+
.pill button { border: 0; border-radius: 0; background: none; padding: 5px 8px; display: inline-flex; align-items: center; gap: 5px; }
|
|
273
|
+
.pill button:hover:not(:disabled) { background: var(--hover); }
|
|
274
|
+
.pill .what { font-weight: 600; }
|
|
275
|
+
.pill.ok .what { color: var(--ok); }
|
|
276
|
+
.pill.bad .what { color: var(--err); }
|
|
277
|
+
.pill .n { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
278
|
+
.pill .again { border-left: 1px solid var(--line); padding: 5px 6px; color: var(--muted); }
|
|
279
|
+
.pill .again:hover:not(:disabled) { color: var(--text); }
|
|
280
|
+
.pill.on { border-color: var(--brand); background: var(--brand-soft); }
|
|
281
|
+
.pill.on .again { border-left-color: var(--brand); }
|
|
282
|
+
.mt-h, .mtable li { display: grid; grid-template-columns: 16px 20px 44px minmax(150px, 3fr) minmax(70px, 1.1fr) 66px 66px 42px 60px 50px 54px 60px minmax(166px, 1.5fr) minmax(120px, 1.3fr) 28px; align-items: center; column-gap: 6px; padding: 8px 16px; }
|
|
254
283
|
.mt-h { border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); padding-top: 10px; padding-bottom: 10px; }
|
|
255
284
|
.mt-h button { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; white-space: nowrap; }
|
|
256
285
|
.mt-h button:hover { color: var(--text); }
|
|
@@ -265,8 +294,8 @@
|
|
|
265
294
|
.mtable .grip { color: var(--faint); touch-action: none; }
|
|
266
295
|
.mtable:not(.drag) .grip { visibility: hidden; }
|
|
267
296
|
.mtable .n { font-weight: 800; color: var(--brand); }
|
|
268
|
-
.mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow-
|
|
269
|
-
.mtable .p { color: var(--muted); overflow-
|
|
297
|
+
.mtable .m { font-family: var(--mono); font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
298
|
+
.mtable .p { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
270
299
|
.mtable .yes { color: var(--ok); }
|
|
271
300
|
.mtable .no { color: var(--faint); }
|
|
272
301
|
.mtable .src { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
@@ -329,6 +358,23 @@
|
|
|
329
358
|
#toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); padding: 10px 18px; border-radius: 10px; background: var(--text); color: var(--bg); opacity: 0; transition: opacity .2s; pointer-events: none; max-width: calc(100% - 32px); z-index: 10; }
|
|
330
359
|
#toast.show { opacity: 1; }
|
|
331
360
|
#toast.err { background: var(--err); color: #fff; }
|
|
361
|
+
/* what's running (tests, loading): a strip at the top of the panel it's about, updated in place */
|
|
362
|
+
.job-slot:empty { display: none; }
|
|
363
|
+
.job-slot.solo { margin: -6px 0 18px; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } /* on its own, under a page's title */
|
|
364
|
+
.job-slot.solo .job:last-child { border-bottom: 0; }
|
|
365
|
+
.job { padding: 10px 16px 0; border-bottom: 1px solid var(--line); background: var(--panel2); font-size: 12.5px; }
|
|
366
|
+
.job .top { display: flex; align-items: center; gap: 10px; min-height: 24px; }
|
|
367
|
+
.job .spin { flex: none; color: var(--brand); }
|
|
368
|
+
.job .what { font-weight: 600; white-space: nowrap; }
|
|
369
|
+
.job .detail { flex: 1; min-width: 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
|
370
|
+
.job .detail .ok { color: var(--ok); }
|
|
371
|
+
.job .detail .bad { color: var(--err); }
|
|
372
|
+
.job .count { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
373
|
+
.job button { padding: 1px 9px; font-size: 12px; }
|
|
374
|
+
.job .bar { margin: 8px -16px 0; height: 3px; background: var(--line); overflow: hidden; }
|
|
375
|
+
.job .bar i { display: block; height: 100%; background: var(--brand); transition: width .3s ease; }
|
|
376
|
+
.job .bar.unknown i { width: 30%; animation: job-slide 1.2s ease-in-out infinite; }
|
|
377
|
+
@keyframes job-slide { from { transform: translateX(-100%); } to { transform: translateX(340%); } }
|
|
332
378
|
|
|
333
379
|
@media (max-width: 1180px) {
|
|
334
380
|
.split { grid-template-columns: 1fr; }
|
|
@@ -376,6 +422,7 @@
|
|
|
376
422
|
</aside>
|
|
377
423
|
|
|
378
424
|
<main>
|
|
425
|
+
<div class="job-slot solo" data-where="page"></div>
|
|
379
426
|
<!-- Providers & models -->
|
|
380
427
|
<section class="page" id="page-providers">
|
|
381
428
|
<div class="head">
|
|
@@ -397,9 +444,17 @@
|
|
|
397
444
|
<div class="hint">Kept in config.json on this machine. This page only shows its first and last characters.</div>
|
|
398
445
|
<div class="actions"><button class="primary" id="p-save">Add provider</button><button class="ghost" id="p-cancel">Cancel</button></div>
|
|
399
446
|
</div>
|
|
400
|
-
<div class="work" id="p-work">
|
|
447
|
+
<div class="work" id="p-work" hidden>
|
|
401
448
|
<div class="card catalog">
|
|
402
|
-
<div class="cat-top"
|
|
449
|
+
<div class="cat-top">
|
|
450
|
+
<div class="cat-row"><input type="search" id="m-search" placeholder="Search all models…"><select id="m-sort" aria-label="sort the models"><option value="name">Sort by name</option><option value="price">Sort by price</option></select></div>
|
|
451
|
+
<div class="cat-row">
|
|
452
|
+
<div class="cat-group"><span class="listsw" id="lists-all" title="load every provider's model list, or none"></span>
|
|
453
|
+
<button class="small ghost" id="fold-all" type="button">Collapse all</button><button class="small ghost" id="unfold-all" type="button">Expand all</button></div>
|
|
454
|
+
<div class="cat-group end"><button class="small" id="dev-all" type="button" title="ask models.dev what each listed model can do: tool calls, context, thinking, images">Get from models.dev</button><button class="small" id="add-all" type="button" title="add every model shown here (only the matches when searching) to your list">Add all</button></div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="job-slot" data-where="catalog"></div>
|
|
403
458
|
<div class="cat-list" id="p-list"></div>
|
|
404
459
|
</div>
|
|
405
460
|
<div class="card mine" id="inuse"></div>
|
|
@@ -451,6 +506,7 @@
|
|
|
451
506
|
<label class="on"><input type="radio" name="l-filter" value="all" checked>All</label><label><input type="radio" name="l-filter" value="failed">Failed</label><label><input type="radio" name="l-filter" value="fallback">Fallbacks</label>
|
|
452
507
|
</div>
|
|
453
508
|
</div>
|
|
509
|
+
<div class="job-slot" data-where="requests"></div>
|
|
454
510
|
<div class="reqs">
|
|
455
511
|
<div class="req-list" id="l-list"></div>
|
|
456
512
|
<div class="req-detail" id="l-detail-pane"><div class="empty">Click a request to see it in full.</div></div>
|
|
@@ -583,15 +639,63 @@ function toast(text, err = false) {
|
|
|
583
639
|
clearTimeout(toastTimer)
|
|
584
640
|
toastTimer = setTimeout(() => (t.className = ''), err ? 6000 : 2400)
|
|
585
641
|
}
|
|
642
|
+
// What's running (a test, lists loading, models.dev), shown at the top of the panels it's about
|
|
643
|
+
// while it runs: job(id, { what, where, done, total, detail, stop }) to show or update it (no total:
|
|
644
|
+
// a bar that just moves), job(id) when it's over. where: the panels ('catalog', 'mine', 'table').
|
|
645
|
+
// Updated in place, so nothing jumps while it counts.
|
|
646
|
+
const JOBS = new Map()
|
|
647
|
+
function job(id, state) {
|
|
648
|
+
if (!state) JOBS.delete(id)
|
|
649
|
+
else JOBS.set(id, { ...JOBS.get(id), ...state, id })
|
|
650
|
+
for (const slot of document.querySelectorAll('.job-slot')) fillSlot(slot)
|
|
651
|
+
}
|
|
652
|
+
// a panel's place for them: made empty, filled now and kept up to date by job()
|
|
653
|
+
const jobSlot = (where) => fillSlot(h('div', { className: 'job-slot', 'data-where': where }))
|
|
654
|
+
function fillSlot(slot) {
|
|
655
|
+
const mine = [...JOBS.values()].filter((j) => j.where.includes(slot.dataset.where))
|
|
656
|
+
for (const el of [...slot.children]) if (!mine.some((j) => j.id === el.dataset.job)) el.remove()
|
|
657
|
+
for (const j of mine) {
|
|
658
|
+
let el = slot.querySelector(`[data-job="${CSS.escape(j.id)}"]`)
|
|
659
|
+
if (!el) {
|
|
660
|
+
el = h('div', { className: 'job', 'data-job': j.id },
|
|
661
|
+
h('div', { className: 'top' }, h('span', { className: 'spin' }), h('span', { className: 'what' }), h('span', { className: 'detail' }), h('span', { className: 'count' }), h('button', { className: 'small', type: 'button' })),
|
|
662
|
+
h('div', { className: 'bar' }, h('i')))
|
|
663
|
+
slot.append(el)
|
|
664
|
+
}
|
|
665
|
+
const known = j.total > 0
|
|
666
|
+
el.querySelector('.what').textContent = j.what
|
|
667
|
+
put(el.querySelector('.detail'), [j.detail ?? ''].flat().map((x) => (x instanceof Node ? x.cloneNode(true) : x))) // a copy: it may show in two panels
|
|
668
|
+
el.querySelector('.count').textContent = known ? `${j.done ?? 0} of ${j.total}` : ''
|
|
669
|
+
const stop = el.querySelector('button')
|
|
670
|
+
stop.hidden = !j.stop
|
|
671
|
+
stop.textContent = j.stopping ? 'Stopping…' : 'Stop'
|
|
672
|
+
stop.disabled = Boolean(j.stopping)
|
|
673
|
+
stop.onclick = () => (j.stop(), job(j.id, { stopping: true }))
|
|
674
|
+
el.querySelector('.bar').classList.toggle('unknown', !known)
|
|
675
|
+
el.querySelector('.bar i').style.width = known ? `${Math.round((100 * (j.done ?? 0)) / j.total)}%` : ''
|
|
676
|
+
}
|
|
677
|
+
return slot
|
|
678
|
+
}
|
|
586
679
|
async function api(path, body) {
|
|
587
680
|
const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-heart-token': token, ...(body !== undefined && { 'content-type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) })
|
|
588
681
|
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
589
682
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
590
683
|
return data
|
|
591
684
|
}
|
|
685
|
+
// What a save or change is doing, said on the page when it takes more than a moment
|
|
686
|
+
const DOING = {
|
|
687
|
+
'/api/provider': 'Checking the address and saving the provider',
|
|
688
|
+
'/api/provider/delete': 'Deleting the provider',
|
|
689
|
+
'/api/provider/list': 'Saving the model list setting',
|
|
690
|
+
'/api/order': 'Saving your models',
|
|
691
|
+
'/api/models-dev': null, // its own strip
|
|
692
|
+
}
|
|
693
|
+
let acting = 0
|
|
592
694
|
async function act(path, body, done) {
|
|
695
|
+
const id = `act${++acting}`
|
|
696
|
+
const slow = DOING[path] !== null && setTimeout(() => job(id, { where: ['page'], what: DOING[path] ?? 'Saving', detail: 'one moment' }), 400)
|
|
593
697
|
try {
|
|
594
|
-
const r = await api(path, body)
|
|
698
|
+
const r = await api(path, body).finally(() => (clearTimeout(slow), job(id)))
|
|
595
699
|
if (r.providers) render(r)
|
|
596
700
|
if (done) toast(done)
|
|
597
701
|
return r
|
|
@@ -602,16 +706,56 @@ async function act(path, body, done) {
|
|
|
602
706
|
}
|
|
603
707
|
const sw = (props) => h('label', { className: 'sw' }, h('input', { type: 'checkbox', ...props }), h('i'))
|
|
604
708
|
|
|
709
|
+
// A long list drawn a screenful at a time: every row the same height, only the rows in view (and a
|
|
710
|
+
// few more each side) on the page, the rest empty room of the right size, so thousands of models
|
|
711
|
+
// scroll smoothly. box: where the rows go; scroller: what scrolls (the box itself, an element
|
|
712
|
+
// around it, or window); row(i): row i's element. Draw it (v.draw()) once the box is on the page.
|
|
713
|
+
const VLISTS = new Set()
|
|
714
|
+
function virtual(box, { count, height, row, scroller = box }) {
|
|
715
|
+
const v = { box, scroller, count, height, shown: new Map(), shift: null }
|
|
716
|
+
box.style.position = 'relative'
|
|
717
|
+
if (scroller === box) box.append(h('li', { className: 'vspace', 'aria-hidden': 'true', style: `height:${count * height}px` }))
|
|
718
|
+
else box.style.height = `${count * height}px`
|
|
719
|
+
v.draw = () => {
|
|
720
|
+
if (!box.isConnected || !box.getClientRects().length) return // not on the page, or folded away
|
|
721
|
+
const view = scroller === window ? { top: 0, height: innerHeight } : { top: scroller.getBoundingClientRect().top, height: scroller.clientHeight }
|
|
722
|
+
const top = scroller === box ? -box.scrollTop : box.getBoundingClientRect().top - view.top // the list's start, from the view's top
|
|
723
|
+
const first = Math.max(0, Math.floor(-top / height) - 6)
|
|
724
|
+
const last = Math.min(count, Math.ceil((view.height - top) / height) + 6)
|
|
725
|
+
for (const [i, el] of v.shown) if ((i < first || i >= last) && !el.classList.contains('lifted')) (el.remove(), v.shown.delete(i))
|
|
726
|
+
for (let i = first; i < last; i++) {
|
|
727
|
+
if (v.shown.has(i)) continue
|
|
728
|
+
const el = row(i)
|
|
729
|
+
Object.assign(el.style, { position: 'absolute', left: 0, right: 0, top: `${i * height}px`, height: `${height}px` })
|
|
730
|
+
el.dataset.i = i
|
|
731
|
+
v.shown.set(i, el)
|
|
732
|
+
box.append(el)
|
|
733
|
+
v.shift?.(el, i)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
VLISTS.add(v)
|
|
737
|
+
return v
|
|
738
|
+
}
|
|
739
|
+
let vFrame = 0
|
|
740
|
+
const drawVirtual = () => (vFrame ||= requestAnimationFrame(() => {
|
|
741
|
+
vFrame = 0
|
|
742
|
+
for (const v of VLISTS) v.box.isConnected ? v.draw() : VLISTS.delete(v)
|
|
743
|
+
}))
|
|
744
|
+
addEventListener('scroll', drawVirtual, { capture: true, passive: true }) // the page's or any list's
|
|
745
|
+
addEventListener('resize', drawVirtual)
|
|
746
|
+
|
|
605
747
|
// pages: one at a time, from the address (#providers…)
|
|
606
748
|
const PAGES = ['providers', 'models', 'general', 'logging', 'prompts', 'mcp', 'skills', 'files']
|
|
607
749
|
const page = () => (PAGES.includes(location.hash.slice(1)) ? location.hash.slice(1) : 'providers')
|
|
608
750
|
function showPage() {
|
|
609
751
|
for (const p of PAGES) $('page-' + p).classList.toggle('on', p === page())
|
|
752
|
+
$('page-' + page()).querySelector('.head').after(document.querySelector('.job-slot[data-where="page"]')) // what's loading: under this page's title
|
|
610
753
|
for (const a of document.querySelectorAll('nav a')) a.classList.toggle('on', a.dataset.page === page())
|
|
611
754
|
if (S) drawUsing(S)
|
|
612
755
|
if (page() === 'logging') drawDisk()
|
|
613
756
|
scrollTo(0, 0)
|
|
614
757
|
fillHeight()
|
|
758
|
+
drawVirtual() // the lists on the page now shown
|
|
615
759
|
document.querySelector('nav a.on')?.scrollIntoView({ inline: 'center', block: 'nearest' }) // on a phone the pages scroll sideways
|
|
616
760
|
}
|
|
617
761
|
addEventListener('hashchange', showPage)
|
|
@@ -715,10 +859,21 @@ $('p-url').oninput = drawPresets
|
|
|
715
859
|
|
|
716
860
|
const MODELS = {} // provider → { list } | { error } | { loading }
|
|
717
861
|
const filters = {}
|
|
862
|
+
// the lists loading now, for the progress card: how many asked for since none was loading, and done
|
|
863
|
+
const listLoads = { total: 0, done: 0, failed: 0 }
|
|
718
864
|
function loadModels(name, again = false) {
|
|
719
865
|
if (MODELS[name] && !again) return
|
|
866
|
+
if (MODELS[name]?.loading) return // already on its way
|
|
720
867
|
MODELS[name] = { loading: true }
|
|
721
|
-
|
|
868
|
+
if (listLoads.done === listLoads.total) Object.assign(listLoads, { total: 0, done: 0, failed: 0 })
|
|
869
|
+
listLoads.total++
|
|
870
|
+
const show = () => listLoads.done === listLoads.total
|
|
871
|
+
? job('lists')
|
|
872
|
+
: job('lists', { where: ['catalog'], what: 'Loading model lists', done: listLoads.done, total: listLoads.total, detail: listLoads.failed ? `${listLoads.failed} couldn't be listed` : 'from each provider, kept for next time' })
|
|
873
|
+
show()
|
|
874
|
+
api('/api/models', { name })
|
|
875
|
+
.then(({ models, details }) => (MODELS[name] = { list: models, details: details ?? {} }), (e) => ((MODELS[name] = { error: e.message }), listLoads.failed++))
|
|
876
|
+
.then(() => (listLoads.done++, show(), S && drawProviders(S)))
|
|
722
877
|
}
|
|
723
878
|
const useModel = (provider, model) => act('/api/model', { provider, model }, `HeartCode now uses ${provider} · ${model}`)
|
|
724
879
|
const saveOrder = (list, done, opts = {}) => act('/api/order', { list, ...opts }, done)
|
|
@@ -765,8 +920,25 @@ for (const id of ['c-name', 'c-what']) $(id).addEventListener('keydown', (e) =>
|
|
|
765
920
|
for (const id of ['p-name', 'p-url', 'p-key']) $(id).addEventListener('keydown', (e) => e.key === 'Enter' && $('p-save').click())
|
|
766
921
|
|
|
767
922
|
// model names in a natural order: "glm-5.2" before "glm-5.10", case doesn't matter
|
|
768
|
-
const byName =
|
|
923
|
+
const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare // one collator: thousands of names sort fast
|
|
769
924
|
let modelQuery = ''
|
|
925
|
+
// the models under each provider: by name, or by what its list says they cost (cheapest first; no price: last)
|
|
926
|
+
let catSort = (() => {
|
|
927
|
+
try {
|
|
928
|
+
return localStorage.getItem('heart.catSort') === 'price' ? 'price' : 'name'
|
|
929
|
+
} catch {
|
|
930
|
+
return 'name'
|
|
931
|
+
}
|
|
932
|
+
})()
|
|
933
|
+
$('m-sort').value = catSort
|
|
934
|
+
$('m-sort').onchange = (e) => {
|
|
935
|
+
catSort = e.target.value
|
|
936
|
+
try {
|
|
937
|
+
localStorage.setItem('heart.catSort', catSort)
|
|
938
|
+
} catch {}
|
|
939
|
+
if (S) drawProviders(S)
|
|
940
|
+
}
|
|
941
|
+
const costOf = (d) => (d?.price ? (d.price.input ?? 0) + (d.price.output ?? 0) : Infinity)
|
|
770
942
|
// Providers folded on the page (the browser remembers them). Typing a search opens them all, until
|
|
771
943
|
// you fold or unfold one (or all) again.
|
|
772
944
|
let searchOpen = false
|
|
@@ -786,6 +958,7 @@ function applyFolds() {
|
|
|
786
958
|
sec.classList.toggle('collapsed', folded)
|
|
787
959
|
sec.querySelector('.fold')?.setAttribute('aria-expanded', String(!folded))
|
|
788
960
|
}
|
|
961
|
+
for (const box of $('p-list').querySelectorAll('.rows')) box._v?.draw() // the ones now open
|
|
789
962
|
}
|
|
790
963
|
// A fold by hand during a search starts from what's shown (the ones it opened stay open).
|
|
791
964
|
function settle() {
|
|
@@ -795,6 +968,20 @@ function settle() {
|
|
|
795
968
|
const fold = (name) => (settle(), FOLDED.has(name) ? FOLDED.delete(name) : FOLDED.add(name), applyFolds())
|
|
796
969
|
$('fold-all').onclick = () => (settle(), S?.providers.forEach((p) => FOLDED.add(p.name)), applyFolds())
|
|
797
970
|
$('unfold-all').onclick = () => (settle(), FOLDED.clear(), applyFolds())
|
|
971
|
+
// every model shown on the left (the search's matches, when searching) added to the end of your list
|
|
972
|
+
$('add-all').onclick = () => {
|
|
973
|
+
if (!S) return
|
|
974
|
+
const seen = new Set(S.order.map(keyOf))
|
|
975
|
+
const add = CATALOG.flatMap(({ provider, ids }) => ids.map((model) => ({ provider, model }))).filter((t) => !seen.has(keyOf(t)) && seen.add(keyOf(t)))
|
|
976
|
+
if (!add.length) return toast(modelQuery.trim() ? 'every match is in your list already' : 'every model shown is in your list already')
|
|
977
|
+
if (!confirm(`Add ${add.length} model${add.length === 1 ? '' : 's'} to your list${modelQuery.trim() ? ` (the ones matching "${modelQuery.trim()}")` : ''}?`)) return
|
|
978
|
+
saveOrder([...S.order, ...add], `${add.length} added: your list has ${S.order.length + add.length} models`)
|
|
979
|
+
}
|
|
980
|
+
// models.dev fetched again (your models' profiles too), then every listed provider's models with it
|
|
981
|
+
$('dev-all').onclick = async () => {
|
|
982
|
+
if (!(await lookUp())) return
|
|
983
|
+
S?.providers.filter((p) => p.list).forEach((p) => loadModels(p.name, true))
|
|
984
|
+
}
|
|
798
985
|
$('m-search').oninput = (e) => ((modelQuery = e.target.value), (searchOpen = true), S && drawProviders(S), applyFolds())
|
|
799
986
|
// A model's tooltip: its full name and what's known about it (from the provider, HeartCode's price
|
|
800
987
|
// list and its own test), and what a click does. Shown on hover and on keyboard focus.
|
|
@@ -817,75 +1004,103 @@ function testLine(t) {
|
|
|
817
1004
|
|
|
818
1005
|
// Test: one short message to the model, to see that it really answers (the key, access to it, its
|
|
819
1006
|
// name, the server). The result stays under it until the page is reloaded.
|
|
820
|
-
const TRIES = {} // "provider/model" → { going } | { ok, ms, reply } | { ok: false, error }
|
|
1007
|
+
const TRIES = {} // "provider/model" → { going } | { ok, ms, reply, at } | { ok: false, status, error, at } (from the server)
|
|
821
1008
|
const keyOf = (t) => `${t.provider}/${t.model}`
|
|
1009
|
+
// What each test result means, for its chip's hover
|
|
1010
|
+
const MEANS = {
|
|
1011
|
+
works: 'it answered',
|
|
1012
|
+
400: '400: the provider refused the request as sent (often: a model that isn\'t a chat model)',
|
|
1013
|
+
401: '401: the key isn\'t accepted',
|
|
1014
|
+
402: '402: it needs payment or credits on your account',
|
|
1015
|
+
403: '403: no access to this model with your plan or key',
|
|
1016
|
+
404: '404: the provider has no model by this name',
|
|
1017
|
+
408: '408: the provider timed out',
|
|
1018
|
+
410: '410: the model was taken down',
|
|
1019
|
+
413: '413: the request was too large for it',
|
|
1020
|
+
422: '422: the provider couldn\'t use the request',
|
|
1021
|
+
429: '429: the provider kept saying it\'s busy (rate limit): try again later',
|
|
1022
|
+
500: '500: the provider\'s server failed',
|
|
1023
|
+
502: '502: the provider (or a proxy before it) couldn\'t reach the model',
|
|
1024
|
+
503: '503: the model or provider is unavailable now',
|
|
1025
|
+
504: '504: the provider timed out',
|
|
1026
|
+
empty: 'it answered, but with no text at all',
|
|
1027
|
+
'no answer': 'no answer within 90 seconds',
|
|
1028
|
+
failed: 'it failed without a status code (a lost connection, an unreadable reply…)',
|
|
1029
|
+
}
|
|
1030
|
+
// What a test came to, in a word: works, its status code (402, 429…), empty, no answer or failed;
|
|
1031
|
+
// null while it runs or before it's tested
|
|
1032
|
+
const outcome = (r) => (!r || r.going ? null : r.ok ? 'works' : r.status ? String(r.status) : /nothing/.test(r.error) ? 'empty' : /within/.test(r.error) ? 'no answer' : 'failed')
|
|
822
1033
|
// The Works? cell: the last result (cut short, all of it on hover) and Test.
|
|
823
1034
|
function triedCell(t) {
|
|
824
1035
|
const r = TRIES[keyOf(t)]
|
|
825
|
-
const secs = r?.ms != null ? ` · ${(r.ms / 1000).toFixed(1)}s` : ''
|
|
1036
|
+
const secs = (r?.ms != null ? ` · ${(r.ms / 1000).toFixed(1)}s` : '') + (r?.at ? ` · tested ${new Date(r.at).toLocaleString()}` : '')
|
|
826
1037
|
const [cls, text, about] = !r ? ['', 'not tested', 'send it a short message to see that it really answers (your key, access to it, its name)']
|
|
827
1038
|
: r.going ? ['going', 'testing…', 'sending it a short message']
|
|
828
1039
|
: r.ok ? ['ok', `✓ ${(r.ms / 1000).toFixed(1)}s`, `works: it answered "${r.reply}"${secs}`]
|
|
829
|
-
: ['bad', `✗ ${
|
|
1040
|
+
: ['bad', `✗ ${outcome(r)}`, `doesn't work: ${r.error}${secs}`] // the reason on hover
|
|
830
1041
|
return h('div', { className: 'try ' + cls },
|
|
831
1042
|
h('span', { className: 'msg', textContent: text, title: about }),
|
|
832
1043
|
h('button', { className: 'small', textContent: 'Test', disabled: Boolean(r?.going), title: 'send it a short message to see that it really answers', 'aria-label': `test ${t.model}`, onclick: () => tryModel(t) }))
|
|
833
1044
|
}
|
|
834
1045
|
function showTried(t) {
|
|
835
1046
|
document.querySelector(`#mt li[data-key="${CSS.escape(keyOf(t))}"] .try`)?.replaceWith(triedCell(t))
|
|
836
|
-
if (
|
|
1047
|
+
if (!TRIES[keyOf(t)]?.going) mineSoon() // a result: the counts by result (and a sort by it) change
|
|
837
1048
|
}
|
|
1049
|
+
let mineFrame = 0
|
|
1050
|
+
const mineSoon = () => (mineFrame ||= requestAnimationFrame(() => ((mineFrame = 0), S && drawMine(S))))
|
|
1051
|
+
// Tests run on the settings server (closing this page doesn't stop them; their results are kept
|
|
1052
|
+
// there for next time): this page asks for them and shows what the server says as it goes.
|
|
838
1053
|
async function tryModel(t) {
|
|
839
|
-
TRIES[keyOf(t)] = { going: true }
|
|
840
|
-
showTried(t)
|
|
841
|
-
const r = (await act('/api/try', { provider: t.provider, model: t.model })) ?? { ok: false, error: 'the settings page did not answer' }
|
|
842
|
-
TRIES[keyOf(t)] = r
|
|
1054
|
+
TRIES[keyOf(t)] = { going: true, before: TRIES[keyOf(t)] }
|
|
843
1055
|
showTried(t)
|
|
1056
|
+
const r = await api('/api/try', { provider: t.provider, model: t.model }).catch((e) => ({ ok: false, error: e.message }))
|
|
1057
|
+
gotResult(keyOf(t), r)
|
|
844
1058
|
return r
|
|
845
1059
|
}
|
|
846
|
-
let testingAll = false
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
testingAll
|
|
850
|
-
const list = [...(S?.order ?? [])]
|
|
851
|
-
let good = 0
|
|
852
|
-
toast(`testing ${list.length} model${list.length === 1 ? '' : 's'}…`)
|
|
853
|
-
// several at once (6), but at most 2 per provider: more would trip its rate limit and show
|
|
854
|
-
// working models as failing
|
|
855
|
-
for (const t of list) TRIES[keyOf(t)] = { going: true }
|
|
856
|
-
list.forEach(showTried)
|
|
857
|
-
const queue = [...list]
|
|
858
|
-
const busy = {}
|
|
859
|
-
const worker = async () => {
|
|
860
|
-
while (queue.length) {
|
|
861
|
-
const i = queue.findIndex((t) => (busy[t.provider] ?? 0) < 2)
|
|
862
|
-
if (i === -1) {
|
|
863
|
-
await new Promise((r) => setTimeout(r, 100)) // every waiting one's provider is busy
|
|
864
|
-
continue
|
|
865
|
-
}
|
|
866
|
-
const [t] = queue.splice(i, 1)
|
|
867
|
-
busy[t.provider] = (busy[t.provider] ?? 0) + 1
|
|
868
|
-
try {
|
|
869
|
-
if ((await tryModel(t)).ok) good++
|
|
870
|
-
} finally {
|
|
871
|
-
busy[t.provider]--
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
1060
|
+
let testingAll = false // a run is going (on the server)
|
|
1061
|
+
// a run of tests: all your models, or the ones given
|
|
1062
|
+
async function tryAll(only) {
|
|
1063
|
+
if (testingAll) return toast('a test is running: wait for it, or stop it', true)
|
|
875
1064
|
try {
|
|
876
|
-
await
|
|
877
|
-
|
|
878
|
-
|
|
1065
|
+
const { run } = await api('/api/tests/start', only ? { only: only.map(({ provider, model }) => ({ provider, model })) } : {})
|
|
1066
|
+
showRun(run)
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
toast(e.message, true)
|
|
879
1069
|
}
|
|
880
|
-
|
|
1070
|
+
}
|
|
1071
|
+
// a model's result, from the server
|
|
1072
|
+
function gotResult(key, result) {
|
|
1073
|
+
TRIES[key] = result
|
|
1074
|
+
const t = S?.order[placeOf(...key.split(/\/(.*)/s))]
|
|
1075
|
+
if (t) showTried(t)
|
|
1076
|
+
}
|
|
1077
|
+
// the run, as the server says it is: its progress strip, and the models waiting marked as testing
|
|
1078
|
+
function showRun(run) {
|
|
1079
|
+
for (const key of run?.pending ?? []) if (!TRIES[key]?.going) TRIES[key] = { going: true, before: TRIES[key] } // its last result, kept if it isn't reached
|
|
1080
|
+
testingAll = Boolean(run && !run.over)
|
|
1081
|
+
if (!testingAll) {
|
|
1082
|
+
for (const [key, r] of Object.entries(TRIES)) if (r.going) r.before ? (TRIES[key] = r.before) : delete TRIES[key] // stopped before it: as it was
|
|
1083
|
+
job('test-all')
|
|
1084
|
+
if (run?.over) toast(`${run.stopping ? `stopped after ${run.done} of ${run.total}: ` : ''}${run.good} of ${run.done} work${run.good === run.done ? '' : `; ${run.done - run.good} don't (Works? says why, on hover)`}`, run.good < run.done)
|
|
1085
|
+
} else
|
|
1086
|
+
job('test-all', {
|
|
1087
|
+
where: ['table', 'mine'],
|
|
1088
|
+
what: run.stopping ? 'Stopping the tests…' : `Testing ${run.some ? '' : 'your '}${run.total} model${run.total === 1 ? '' : 's'}${run.each > 1 ? ` · ${run.each} at a time per provider` : ''}`,
|
|
1089
|
+
done: run.done, total: run.total, stopping: run.stopping,
|
|
1090
|
+
detail: [h('span', { className: 'ok', textContent: `✓ ${run.good} work` }), ' · ', h('span', { className: 'bad', textContent: `✗ ${run.done - run.good} don't` }), run.busy.length ? ` · now: ${run.busy.join(', ')}` : '', Object.keys(run.paused ?? {}).length ? ` · paused (busy, 429): ${Object.entries(run.paused).map(([p, secs]) => `${p} ${secs}s`).join(', ')}` : ''],
|
|
1091
|
+
stop: () => api('/api/tests/stop', {}).catch((e) => toast(e.message, true)),
|
|
1092
|
+
})
|
|
1093
|
+
mineSoon()
|
|
881
1094
|
}
|
|
882
1095
|
|
|
883
1096
|
// models.dev looked up again: one model (its Retry), or all of yours (Refresh from models.dev)
|
|
884
1097
|
const retry = (t) => h('button', { className: 'small', textContent: 'Retry', title: 'look it up in models.dev again', onclick: (e) => (e.stopPropagation(), lookUp(t)) })
|
|
885
1098
|
async function lookUp(t) {
|
|
886
|
-
|
|
1099
|
+
job('models.dev', { where: ['catalog', 'mine', 'table'], what: t ? `Looking up ${t.model} in models.dev` : 'Asking models.dev about your models', detail: 'context, tool calls, thinking, images' })
|
|
887
1100
|
const r = await act('/api/models-dev', t ? { provider: t.provider, model: t.model } : {})
|
|
1101
|
+
job('models.dev')
|
|
888
1102
|
if (r) toast(t ? (r.found ? `${t.model}: found in models.dev` : `${t.model}: still not in models.dev`) : `models.dev: ${r.found} of ${r.of} found`)
|
|
1103
|
+
return r
|
|
889
1104
|
}
|
|
890
1105
|
|
|
891
1106
|
// The model's setup: a window over the page (nothing on it moves). Its context window (empty: as
|
|
@@ -990,8 +1205,26 @@ function drawProviders(s) {
|
|
|
990
1205
|
const list = [...order]
|
|
991
1206
|
const [t] = list.splice(i, 1)
|
|
992
1207
|
list.splice(j, 0, t)
|
|
993
|
-
save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1208
|
+
return save(list, list[0] !== order[0] ? `HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
994
1209
|
}
|
|
1210
|
+
// a row of your list (the last: where the next one goes)
|
|
1211
|
+
const mineRow = (i) => {
|
|
1212
|
+
if (i === order.length) return h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` })
|
|
1213
|
+
const t = order[i]
|
|
1214
|
+
return h('li', {
|
|
1215
|
+
className: 'in' + (i === 0 ? ' use' : ''), tabIndex: 0, 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
|
|
1216
|
+
onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
|
|
1217
|
+
},
|
|
1218
|
+
h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
|
|
1219
|
+
h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
|
|
1220
|
+
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1221
|
+
h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
|
|
1222
|
+
testLine(t),
|
|
1223
|
+
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1224
|
+
}
|
|
1225
|
+
const mine = order.length ? h('ol', { className: 'olist' }) : null
|
|
1226
|
+
const mineV = mine && virtual(mine, { count: order.length + 1, height: MINE_ROW, row: mineRow })
|
|
1227
|
+
if (mineV) mineV.items = order.length
|
|
995
1228
|
put($('inuse'),
|
|
996
1229
|
h('div', { className: 'mine-top' },
|
|
997
1230
|
h('div', { className: 'mine-title' },
|
|
@@ -1000,18 +1233,9 @@ function drawProviders(s) {
|
|
|
1000
1233
|
h('button', { className: 'small', textContent: 'Refresh from models.dev', title: 'ask models.dev again what each model can do: context, tools, thinking, images, price', style: order.length ? '' : 'visibility:hidden', onclick: () => lookUp() }),
|
|
1001
1234
|
h('button', { className: 'small danger', textContent: 'Remove all', title: 'take every model off this list', style: order.length ? '' : 'visibility:hidden', onclick: () => confirm(`Take all ${order.length} model${order.length === 1 ? '' : 's'} off your list? HeartCode then has none to use until you choose one. (They stay under their providers.)`) && saveOrder([], 'all removed: no model chosen now') })),
|
|
1002
1235
|
h('p', { className: 'sub', style: 'margin:0', textContent: order.length > 1 ? 'HeartCode uses #1. If it fails, it tries #2, then #3… and round again. Drag to change the order.' : order.length ? `HeartCode uses this one. Click more ${side} to add fallbacks: tried, in order, when one fails.` : `The models HeartCode uses, in the order it tries them. Click one ${side} to start.` })),
|
|
1236
|
+
jobSlot('mine'),
|
|
1003
1237
|
order.length ? h('div', { className: 'ohead' }, h('span'), h('span', { textContent: '#' }), h('span', { textContent: 'Model' }), h('span', { textContent: 'Provider' }), h('span', { textContent: 'What it can do' }), h('span')) : null,
|
|
1004
|
-
order.length ?
|
|
1005
|
-
className: 'in', 'data-key': `${t.provider}/${t.model}`, title: order.length > 1 ? 'Drag to move it (or Alt+↑ / Alt+↓)' : '',
|
|
1006
|
-
onkeydown: (e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), move(i, Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowUp' ? -1 : 1))))),
|
|
1007
|
-
},
|
|
1008
|
-
h('span', { className: 'grip', textContent: '⋮⋮', title: 'drag to reorder', 'aria-hidden': 'true', style: order.length > 1 ? '' : 'visibility:hidden' }),
|
|
1009
|
-
h('span', { className: 'n', textContent: `#${i + 1}`, title: i === 0 ? 'in use: the one HeartCode uses' : 'a fallback: tried, in order, when the ones above fail' }),
|
|
1010
|
-
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1011
|
-
h('span', { className: 'p' + (i === 0 ? ' use' : ''), textContent: t.provider, title: `${t.provider}${i === 0 ? ': in use' : ''}` }),
|
|
1012
|
-
testLine(t),
|
|
1013
|
-
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) })),
|
|
1014
|
-
), h('li', { className: 'next', textContent: `#${order.length + 1} · click a model ${side} to add it here (a fallback)` }))
|
|
1238
|
+
order.length ? mine
|
|
1015
1239
|
: h('div', { className: 'mine-empty' }, h('b', { textContent: 'No model chosen yet' }), `Click a model ${side}: it becomes #1, the one HeartCode uses.`),
|
|
1016
1240
|
order.length > 1 ? h('div', { className: 'mine-rules' },
|
|
1017
1241
|
h('label', { className: 'f', for: 'r-on', textContent: 'When the one in use fails, go on to the next' }),
|
|
@@ -1024,12 +1248,16 @@ function drawProviders(s) {
|
|
|
1024
1248
|
h('select', { id: 'r-next', onchange: (e) => save(order, 'saved', { next: e.target.value }) },
|
|
1025
1249
|
[['first', '#1 again'], ['stay', 'the model that answered last']].map(([v, t]) => h('option', { value: v, textContent: t, selected: s.rotation.next === v })))) : null)
|
|
1026
1250
|
|
|
1027
|
-
|
|
1251
|
+
mineV?.draw()
|
|
1252
|
+
sortable(mineV, move)
|
|
1028
1253
|
|
|
1029
1254
|
// left: every provider's models, A to Z; one click adds it (the first one becomes #1). Built
|
|
1030
1255
|
// again only when what it lists changes (a provider, its models, the search); a change to your
|
|
1031
1256
|
// list only updates the rows it touches (markCatalog), so the list stays where you scrolled it.
|
|
1032
|
-
const shape = JSON.stringify([s.providers, s.providers.map((p) => (MODELS[p.name]?.list ?? []).length + (MODELS[p.name]?.error ?? '') + (MODELS[p.name]
|
|
1257
|
+
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
|
+
// every provider's list at once: on when all are
|
|
1259
|
+
const allOn = s.providers.length > 0 && s.providers.every((p) => p.list)
|
|
1260
|
+
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'])
|
|
1033
1261
|
if (shape !== catalogShape) (buildCatalog(s), applyFolds())
|
|
1034
1262
|
catalogShape = shape
|
|
1035
1263
|
markCatalog()
|
|
@@ -1039,60 +1267,77 @@ function drawProviders(s) {
|
|
|
1039
1267
|
// Your models: pick one up (anywhere on it with a mouse, by its ⋮⋮ on a touch screen) and the
|
|
1040
1268
|
// others move aside as it goes; let go and it's saved, already in its place (no jump when the
|
|
1041
1269
|
// page redraws from what's saved).
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1270
|
+
// Drag a row of a long list (virtual) to move it: it follows the pointer, the others make room, and
|
|
1271
|
+
// near the list's top or bottom edge it scrolls (faster nearer the edge). onMove(from, to) when
|
|
1272
|
+
// dropped; the list is drawn again once the order is saved (put back if saving failed).
|
|
1273
|
+
const MINE_ROW = 50 // your models, on the right
|
|
1274
|
+
const TABLE_ROW = 46 // the Your models page's table
|
|
1275
|
+
function sortable(v, onMove) {
|
|
1276
|
+
if (!v || v.items < 2) return
|
|
1277
|
+
const { box, scroller, height } = v
|
|
1278
|
+
const scrolled = () => (scroller === window ? scrollY : scroller.scrollTop)
|
|
1279
|
+
box.addEventListener('pointerdown', (e) => {
|
|
1045
1280
|
const li = e.target.closest('li.in')
|
|
1046
1281
|
if (!li || e.button !== 0 || e.target.closest('button, a, input, select, textarea')) return
|
|
1047
1282
|
if (e.pointerType !== 'mouse' && !e.target.closest('.grip')) return // a finger scrolls the list, except on the grip
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
const from = items.indexOf(li)
|
|
1051
|
-
const rects = items.map((x) => x.getBoundingClientRect())
|
|
1052
|
-
const step = rects[1].top - rects[0].top // a card and the space after it
|
|
1053
|
-
const centers = rects.map((r) => r.top + r.height / 2)
|
|
1283
|
+
const n = v.items
|
|
1284
|
+
const from = Number(li.dataset.i)
|
|
1054
1285
|
const startY = e.clientY
|
|
1286
|
+
const startScroll = scrolled()
|
|
1287
|
+
let y = startY
|
|
1055
1288
|
let to = from
|
|
1056
1289
|
let moving = false
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1290
|
+
let frame = 0
|
|
1291
|
+
// the others: out of the way of the one moving (also the ones drawn while it moves)
|
|
1292
|
+
v.shift = (el, k) => {
|
|
1293
|
+
if (!moving || el === li) return
|
|
1294
|
+
const by = from < to && k > from && k <= to ? -height : from > to && k >= to && k < from ? height : 0
|
|
1295
|
+
el.style.transform = by ? `translateY(${by}px)` : ''
|
|
1296
|
+
}
|
|
1297
|
+
const place = () => {
|
|
1298
|
+
const dy = Math.max(-from * height, Math.min((n - 1 - from) * height, y - startY + scrolled() - startScroll)) // within the list
|
|
1299
|
+
li.style.transform = `translateY(${dy}px)`
|
|
1300
|
+
to = Math.max(0, Math.min(n - 1, Math.round(from + dy / height)))
|
|
1301
|
+
v.shown.forEach((el, k) => v.shift(el, k))
|
|
1302
|
+
}
|
|
1303
|
+
const edge = () => {
|
|
1304
|
+
const r = scroller === window ? { top: 0, bottom: innerHeight } : scroller.getBoundingClientRect()
|
|
1305
|
+
const d = y < r.top + 48 ? y - (r.top + 48) : y > r.bottom - 48 ? y - (r.bottom - 48) : 0
|
|
1306
|
+
if (d) {
|
|
1307
|
+
;(scroller === window ? window : scroller).scrollBy(0, Math.sign(d) * Math.min(28, 2 + Math.abs(d) / 2))
|
|
1308
|
+
v.draw()
|
|
1309
|
+
place()
|
|
1310
|
+
}
|
|
1311
|
+
frame = requestAnimationFrame(edge)
|
|
1312
|
+
}
|
|
1062
1313
|
const onMovePointer = (ev) => {
|
|
1063
|
-
|
|
1314
|
+
y = ev.clientY
|
|
1064
1315
|
if (!moving) {
|
|
1065
|
-
if (Math.abs(
|
|
1316
|
+
if (Math.abs(y - startY) < 4) return
|
|
1066
1317
|
moving = true
|
|
1067
|
-
li.setPointerCapture(e.pointerId)
|
|
1068
1318
|
li.classList.add('lifted')
|
|
1069
|
-
|
|
1319
|
+
box.classList.add('sorting')
|
|
1070
1320
|
document.body.classList.add('sorting')
|
|
1071
1321
|
$('tip').classList.remove('show')
|
|
1072
1322
|
getSelection()?.removeAllRanges()
|
|
1323
|
+
frame = requestAnimationFrame(edge)
|
|
1073
1324
|
}
|
|
1074
|
-
|
|
1075
|
-
li.style.transform = `translateY(${y}px)`
|
|
1076
|
-
const mid = centers[from] + y
|
|
1077
|
-
to = items.filter((x, k) => k !== from && centers[k] < mid).length
|
|
1078
|
-
shift()
|
|
1325
|
+
place()
|
|
1079
1326
|
}
|
|
1080
1327
|
const done = () => {
|
|
1081
1328
|
removeEventListener('pointermove', onMovePointer)
|
|
1082
1329
|
removeEventListener('pointerup', done)
|
|
1083
1330
|
removeEventListener('pointercancel', done)
|
|
1331
|
+
cancelAnimationFrame(frame)
|
|
1332
|
+
v.shift = null
|
|
1084
1333
|
if (!moving) return
|
|
1085
1334
|
document.body.classList.remove('sorting')
|
|
1086
|
-
|
|
1335
|
+
box.classList.remove('sorting')
|
|
1087
1336
|
li.classList.remove('lifted')
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (to !== from) items[to].insertAdjacentElement(to > from ? 'afterend' : 'beforebegin', li)
|
|
1093
|
-
ol.querySelectorAll('li.in').forEach((x, k) => (x.querySelector('.n').textContent = `#${k + 1}`))
|
|
1094
|
-
requestAnimationFrame(() => items.forEach((x) => (x.style.transition = '')))
|
|
1095
|
-
onMove(from, to)
|
|
1337
|
+
const back = () => v.shown.forEach((el) => (el.style.transform = ''))
|
|
1338
|
+
if (to === from) return back()
|
|
1339
|
+
li.style.transform = `translateY(${(to - from) * height}px)` // in its new place while it's saved
|
|
1340
|
+
Promise.resolve(onMove(from, to)).then((r) => r || back())
|
|
1096
1341
|
}
|
|
1097
1342
|
addEventListener('pointermove', onMovePointer)
|
|
1098
1343
|
addEventListener('pointerup', done)
|
|
@@ -1102,16 +1347,25 @@ function sortable(ol, onMove) {
|
|
|
1102
1347
|
|
|
1103
1348
|
// --- the models on the left: rows that read your list when clicked or hovered (S.order, now) ---
|
|
1104
1349
|
let catalogShape = null
|
|
1350
|
+
// where a model is in your list (undefined: not in it); looked up, not searched (thousands of rows ask)
|
|
1351
|
+
let places = { of: null, at: null }
|
|
1105
1352
|
const placeOf = (provider, id) => {
|
|
1106
|
-
|
|
1107
|
-
return
|
|
1353
|
+
if (places.of !== S.order) places = { of: S.order, at: new Map(S.order.map((t, i) => [`${t.provider}/${t.model}`, i])) }
|
|
1354
|
+
return places.at.get(`${provider}/${id}`)
|
|
1108
1355
|
}
|
|
1109
1356
|
// A model's row on the left: scrolled to and lit up for a moment (one just added by name).
|
|
1110
1357
|
function showRow(provider, id, said) {
|
|
1111
1358
|
toast(said)
|
|
1112
|
-
const
|
|
1359
|
+
const list = $('p-list')
|
|
1360
|
+
const box = [...list.querySelectorAll(`section[data-provider="${CSS.escape(provider)}"] .rows`)].find((b) => b._ids)
|
|
1361
|
+
const i = box?._ids.indexOf(id) ?? -1
|
|
1362
|
+
if (i < 0) return
|
|
1363
|
+
const at = box.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop + i * CAT_ROW
|
|
1364
|
+
if (at < list.scrollTop + 120 || at + CAT_ROW > list.scrollTop + list.clientHeight) list.scrollTop = at - list.clientHeight / 2
|
|
1365
|
+
box._v.draw()
|
|
1366
|
+
const el = box._v.shown.get(i)
|
|
1367
|
+
const row = el?.matches('.mrow') ? el : el?.querySelector('.mrow')
|
|
1113
1368
|
if (!row) return
|
|
1114
|
-
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
1115
1369
|
row.classList.remove('flash')
|
|
1116
1370
|
void row.offsetWidth // restart the animation
|
|
1117
1371
|
row.classList.add('flash')
|
|
@@ -1130,7 +1384,29 @@ function rowTail(provider, id) {
|
|
|
1130
1384
|
const at = placeOf(provider, id)
|
|
1131
1385
|
return at === 0 ? h('span', { className: 'tag brand', textContent: '#1 in use' }) : at > 0 ? h('span', { className: 'tag brand', textContent: `#${at + 1} fallback` }) : h('span', { className: 'add', textContent: S.order.length ? '+ Add' : 'Use' })
|
|
1132
1386
|
}
|
|
1387
|
+
// what a listed model can do: as tested or set up, else what models.dev says (one of yours: its
|
|
1388
|
+
// profile, also when the provider's list is off)
|
|
1389
|
+
// what the provider's own list says it costs, per million tokens
|
|
1390
|
+
const priceText = (p) => (!p ? '' : !p.input && !p.output ? 'free' : [p.input != null && `${p.input ? usd(p.input) : '$0'} in`, p.output != null && `${p.output ? usd(p.output) : '$0'} out`].filter(Boolean).join(' / ') + ' per M')
|
|
1391
|
+
function canCell(d, mine) {
|
|
1392
|
+
const price = d?.price
|
|
1393
|
+
if (mine?.tested) d = { tested: mine.tested }
|
|
1394
|
+
const k = d?.known
|
|
1395
|
+
const can = d?.tested?.source === 'assumed' ? 'not in models.dev' : d?.tested ? testedText(d.tested) : k ? testedText({ mode: k.tools ? 'tools' : '', context: k.context, thinking: k.thinking, vision: k.images }) : d ? 'not in models.dev' : ''
|
|
1396
|
+
const text = [can, priceText(price)].filter(Boolean).join(' · ')
|
|
1397
|
+
return text ? h('span', { className: 'can', textContent: text }) : null
|
|
1398
|
+
}
|
|
1399
|
+
// every model added to a provider by name forgotten; the ones in your list leave it too
|
|
1400
|
+
async function clearAdded(p) {
|
|
1401
|
+
const inList = S.order.filter((t) => t.provider === p.name && p.added.includes(t.model))
|
|
1402
|
+
if (!confirm(`Forget the ${p.added.length} model${p.added.length === 1 ? '' : 's'} you added to ${p.name}?${inList.length ? ` ${inList.length} of them ${inList.length === 1 ? 'is' : 'are'} in your list and will leave it.` : ''}`)) return
|
|
1403
|
+
if (inList.length && !(await saveOrder(S.order.filter((t) => !inList.includes(t))))) return
|
|
1404
|
+
act('/api/provider/added', { name: p.name, all: true }, `${p.name}: the models you added are forgotten`)
|
|
1405
|
+
}
|
|
1406
|
+
const CAT_ROW = 56 // a model's row on the left: its name, and what it can do under it
|
|
1407
|
+
let CATALOG = [] // what the left shows: [{ provider, ids }] (Add all adds these)
|
|
1133
1408
|
function buildCatalog(s) {
|
|
1409
|
+
CATALOG = []
|
|
1134
1410
|
const q = modelQuery.trim()
|
|
1135
1411
|
// a name no provider lists (anywhere): offered under each provider, to use it anyway
|
|
1136
1412
|
const anyMatch = !q || s.providers.some((p) => (MODELS[p.name]?.list ?? []).some((id) => id.toLowerCase().includes(q.toLowerCase())))
|
|
@@ -1141,10 +1417,10 @@ function buildCatalog(s) {
|
|
|
1141
1417
|
// and the models added by name (and any of yours it doesn't list), marked as yours
|
|
1142
1418
|
const listed = new Set(m.list ?? [])
|
|
1143
1419
|
const yours = [...new Set([...p.added, ...s.order.filter((t) => t.provider === p.name).map((t) => t.model)])].filter((id) => !listed.has(id))
|
|
1144
|
-
const ids = [...listed, ...yours].sort(byName).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
|
|
1420
|
+
const ids = [...listed, ...yours].sort((a, b) => (catSort === 'price' && costOf(m.details?.[a]) - costOf(m.details?.[b])) || byName(a, b)).filter((id) => id.toLowerCase().includes(q.toLowerCase()))
|
|
1145
1421
|
const row = (id, own) =>
|
|
1146
1422
|
h('button', { className: 'mrow', 'data-provider': p.name, 'data-model': id, onclick: () => (placeOf(p.name, id) === undefined ? addModel(p.name, id) : removeMine(p.name, id)) },
|
|
1147
|
-
h('span', { className: 'id',
|
|
1423
|
+
h('span', { className: 'id', title: id }, id, canCell(m.details?.[id], S.order[placeOf(p.name, id)])),
|
|
1148
1424
|
own ? h('span', { className: 'tag', textContent: 'not in its list' }) : p.added.includes(id) ? h('span', { className: 'tag', textContent: 'added by you' }) : null,
|
|
1149
1425
|
rowTail(p.name, id))
|
|
1150
1426
|
// one you added: ✕ forgets it (it leaves your list too)
|
|
@@ -1155,6 +1431,7 @@ function buildCatalog(s) {
|
|
|
1155
1431
|
act('/api/provider/added', { name: p.name, model: id, remove: true }, `forgot ${id}`)
|
|
1156
1432
|
} }))
|
|
1157
1433
|
const typed = !anyMatch ? [row(q, true)] : []
|
|
1434
|
+
if (typed[0]) typed[0].dataset.typed = '1' // a name no provider lists: not part of Add all
|
|
1158
1435
|
if (q && anyMatch && !ids.length) return null // searching: only the providers with a match
|
|
1159
1436
|
// a model its list leaves out (some servers don't list them all): by its exact name
|
|
1160
1437
|
const name = h('input', { type: 'text', placeholder: m.off ? 'List off: a model\'s exact name' : 'Custom model: its exact name', 'aria-label': `add a model to ${p.name} by name` })
|
|
@@ -1180,9 +1457,18 @@ function buildCatalog(s) {
|
|
|
1180
1457
|
() => 'On: its models are loaded from the provider. Off: never asked for; only the models you add by name show.')),
|
|
1181
1458
|
h('button', { className: 'small', textContent: 'Edit', onclick: () => openForm(p) }),
|
|
1182
1459
|
h('button', { className: 'small danger', textContent: 'Delete', onclick: () => confirm(`Delete the provider "${p.name}"? Its models leave your list too.`) && act('/api/provider/delete', { name: p.name }, `deleted ${p.name}`) })),
|
|
1183
|
-
h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go })
|
|
1460
|
+
h('div', { className: 'byname' }, name, h('button', { className: 'small byname-go', textContent: 'Add', onclick: go }),
|
|
1461
|
+
p.added.length ? h('button', { className: 'small danger', textContent: `Clear added (${p.added.length})`, title: `forget the ${p.added.length} model${p.added.length === 1 ? '' : 's'} you added to ${p.name} by name`, onclick: () => clearAdded(p) }) : null),
|
|
1184
1462
|
// what's listed first; loading and errors under it (the models already shown stay where they are)
|
|
1185
|
-
typed.length
|
|
1463
|
+
typed.length ? h('div', { className: 'rows' }, typed) : null,
|
|
1464
|
+
(() => {
|
|
1465
|
+
if (!ids.length) return null
|
|
1466
|
+
CATALOG.push({ provider: p.name, ids })
|
|
1467
|
+
const box = h('div', { className: 'rows' })
|
|
1468
|
+
box._ids = ids
|
|
1469
|
+
box._v = virtual(box, { count: ids.length, height: CAT_ROW, row: (i) => item(ids[i]), scroller: $('p-list') })
|
|
1470
|
+
return box
|
|
1471
|
+
})(),
|
|
1186
1472
|
m.error ? h('div', { className: 'cat-note', style: 'color:var(--err)' }, `can't list its models: ${shortError(m.error)} `, h('button', { className: 'small', textContent: 'Try again', onclick: () => loadModels(p.name, true) }), h('div', { className: 'muted', style: 'margin-top:4px', textContent: 'Or add a model by its exact name, above.' })) : null,
|
|
1187
1473
|
m.loading ? h('div', { className: 'cat-note', textContent: 'Loading its models…' }) : null)
|
|
1188
1474
|
}))
|
|
@@ -1238,10 +1524,34 @@ function sorted(order) {
|
|
|
1238
1524
|
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)
|
|
1239
1525
|
}
|
|
1240
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')
|
|
1527
|
+
// The models ticked on the Your models page ("provider/model"), to remove several at once; the last
|
|
1528
|
+
// one ticked, so a Shift+click ticks the ones between
|
|
1529
|
+
const PICKED = new Set()
|
|
1530
|
+
let lastPicked = null
|
|
1241
1531
|
function drawMine(s) {
|
|
1242
1532
|
const order = s.order
|
|
1243
1533
|
$('n-mine').textContent = order.length || ''
|
|
1244
1534
|
const view = sorted(order)
|
|
1535
|
+
for (const k of PICKED) if (placeOf(...k.split(/\/(.*)/s)) === undefined) PICKED.delete(k) // gone from the list
|
|
1536
|
+
const removePicked = () => {
|
|
1537
|
+
const rest = order.filter((t) => !PICKED.has(keyOf(t)))
|
|
1538
|
+
const n = order.length - rest.length
|
|
1539
|
+
if (!confirm(`Take ${n} model${n === 1 ? '' : 's'} off your list?${!rest.length ? ' None is left then: HeartCode has no model to use until you choose one.' : rest[0] !== order[0] ? ` HeartCode then uses ${rest[0].provider} · ${rest[0].model}.` : ''} (They stay under their providers.)`)) return
|
|
1540
|
+
PICKED.clear()
|
|
1541
|
+
saveOrder(rest, `${n} removed${rest.length ? '' : ': no model chosen now'}`)
|
|
1542
|
+
}
|
|
1543
|
+
const all = view.length > 0 && view.every((t) => PICKED.has(keyOf(t)))
|
|
1544
|
+
// the tests' results, by outcome: a click selects those models (again: unselects them)
|
|
1545
|
+
const outcomes = Map.groupBy(order.filter((t) => outcome(TRIES[keyOf(t)])), (t) => outcome(TRIES[keyOf(t)]))
|
|
1546
|
+
const byOutcome = [...outcomes].sort(([a, x], [b, y]) => (b === 'works') - (a === 'works') || y.length - x.length).map(([what, models]) => {
|
|
1547
|
+
const on = models.every((t) => PICKED.has(keyOf(t)))
|
|
1548
|
+
const n = models.length
|
|
1549
|
+
return h('span', { className: 'pill' + (what === 'works' ? ' ok' : ' bad') + (on ? ' on' : '') },
|
|
1550
|
+
h('button', { type: 'button', className: 'pick', title: `${MEANS[what] ?? `the provider answered ${what}`}\n${on ? 'Unselect' : 'Select'} the ${n} model${n === 1 ? '' : 's'}`, onclick: () => (models.forEach((t) => (on ? PICKED.delete(keyOf(t)) : PICKED.add(keyOf(t)))), drawMine(S)) },
|
|
1551
|
+
h('span', { className: 'what', textContent: what === 'works' ? '✓ works' : `✗ ${what}` }), h('span', { className: 'n', textContent: n.toLocaleString() })),
|
|
1552
|
+
what === 'works' ? null : h('button', { type: 'button', className: 'again', textContent: '↻', title: `Test these ${n} again`, 'aria-label': `test the ${what} ones again`, disabled: testingAll, onclick: () => tryAll(models) }))
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1245
1555
|
const same = view.every((t, i) => t === order[i])
|
|
1246
1556
|
// the order you see, saved as yours; the sort then goes (your own order from here on: drag on)
|
|
1247
1557
|
const keep = (list, moved) => {
|
|
@@ -1249,7 +1559,7 @@ function drawMine(s) {
|
|
|
1249
1559
|
try {
|
|
1250
1560
|
localStorage.setItem('heart.sort', '[]')
|
|
1251
1561
|
} catch {}
|
|
1252
|
-
saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1562
|
+
return saveOrder(list, list[0] !== order[0] ? `${moved ? '' : 'order saved: '}HeartCode now uses ${list[0].provider} · ${list[0].model}` : 'order saved')
|
|
1253
1563
|
}
|
|
1254
1564
|
// a row dragged (in the order shown, sorted or not)
|
|
1255
1565
|
const move = (i, j) => {
|
|
@@ -1257,51 +1567,76 @@ function drawMine(s) {
|
|
|
1257
1567
|
const list = [...view]
|
|
1258
1568
|
const [t] = list.splice(i, 1)
|
|
1259
1569
|
list.splice(j, 0, t)
|
|
1260
|
-
keep(list, true)
|
|
1261
|
-
}
|
|
1262
|
-
const yes = (v) => h('span', { className: v ? 'yes' : 'no', textContent: v ? '✓' : '–' })
|
|
1263
|
-
const src = (t) => {
|
|
1264
|
-
const x = t.tested
|
|
1265
|
-
if (!x) return h('div', { className: 'src', textContent: 'looking it up…' })
|
|
1266
|
-
if (x.source === 'assumed') return h('div', { className: 'src warn', title: "models.dev doesn't know it: Retry, or set up what it can do" }, 'not found', retry(t), h('button', { className: 'small', textContent: 'Set up', onclick: () => openSetup(t) }))
|
|
1267
|
-
return h('div', { className: 'src' }, Object.keys(x.set ?? {}).length ? 'set by you' : x.source === 'models.dev' ? 'models.dev' : FROM[x.source], h('button', { className: 'small', textContent: 'Edit', onclick: () => openSetup(t) }))
|
|
1570
|
+
return keep(list, true)
|
|
1268
1571
|
}
|
|
1269
1572
|
const head = (key, num) => {
|
|
1270
1573
|
const at = SORT.findIndex((x) => x.key === key)
|
|
1271
1574
|
return h('button', { type: 'button', className: (at >= 0 ? 'on ' : '') + (num ? 'num' : ''), title: `sort by ${COLS[key].label.toLowerCase()}`, onclick: () => clickSort(key) },
|
|
1272
1575
|
COLS[key].label, at >= 0 ? ` ${SORT[at].dir === 'up' ? '↑' : '↓'}${SORT.length > 1 ? at + 1 : ''}` : '')
|
|
1273
1576
|
}
|
|
1577
|
+
const table = order.length ? h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }) : null
|
|
1578
|
+
// ticked or not; with Shift, every row from the last one ticked to this one
|
|
1579
|
+
const pick = (i, e) => {
|
|
1580
|
+
const on = !PICKED.has(keyOf(view[i]))
|
|
1581
|
+
const from = e.shiftKey && lastPicked !== null ? Math.min(lastPicked, i) : i
|
|
1582
|
+
const to = e.shiftKey && lastPicked !== null ? Math.max(lastPicked, i) : i
|
|
1583
|
+
for (let k = from; k <= to; k++) on ? PICKED.add(keyOf(view[k])) : PICKED.delete(keyOf(view[k]))
|
|
1584
|
+
lastPicked = i
|
|
1585
|
+
drawMine(S)
|
|
1586
|
+
}
|
|
1587
|
+
const tableV = table && virtual(table, { count: view.length, height: TABLE_ROW, row: (i) => tableRow(view[i], placeOf(view[i].provider, view[i].model), (e) => pick(i, e)), scroller: window })
|
|
1588
|
+
if (tableV) tableV.items = view.length
|
|
1274
1589
|
put($('mt'),
|
|
1275
1590
|
h('div', { className: 'mt-top' },
|
|
1276
1591
|
h('div', { className: 'sorts' }, SORT.length
|
|
1277
1592
|
? ['Sorted by ', SORT.map(({ key, dir }, k) => h('span', { className: 'chip' }, `${k + 1}. ${COLS[key].label}: ${dirText(key, dir)}`, h('button', { type: 'button', textContent: '✕', 'aria-label': `stop sorting by ${COLS[key].label}`, onclick: () => setSort(SORT.filter((x) => x.key !== key)) })))]
|
|
1278
1593
|
: order.length > 1 ? 'In your order: HeartCode uses #1, then the next when one fails. Drag a row to move it, or click a column to sort.' : ''),
|
|
1594
|
+
PICKED.size ? h('button', { className: 'small danger', textContent: `Remove ${PICKED.size} selected`, onclick: removePicked }) : null,
|
|
1595
|
+
PICKED.size ? h('button', { className: 'small', textContent: `Test ${PICKED.size} selected`, disabled: testingAll, onclick: () => tryAll(order.filter((t) => PICKED.has(keyOf(t)))) }) : null,
|
|
1596
|
+
PICKED.size ? h('button', { className: 'small', textContent: 'Clear selection', onclick: () => (PICKED.clear(), drawMine(S)) }) : null,
|
|
1279
1597
|
SORT.length ? h('button', { className: 'small', textContent: 'Clear sort', onclick: () => setSort([]) }) : null,
|
|
1280
1598
|
SORT.length ? h('button', { className: 'small primary', textContent: same ? 'This is your order' : 'Use this order', disabled: same, onclick: () => keep(view) }) : null,
|
|
1281
1599
|
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,
|
|
1282
|
-
order.length ? h('button', { className: 'small', textContent: 'Test all', title: 'send each a short message
|
|
1600
|
+
order.length ? h('button', { className: 'small', textContent: 'Test all', disabled: testingAll, title: 'send each a short message to see which really answer: every provider at once, a few from each (one at a time per 100 models)', onclick: () => tryAll() }) : null),
|
|
1283
1601
|
order.length
|
|
1284
1602
|
? h('div', { className: 'mt-scroll' }, h('div', { className: 'mt' },
|
|
1285
|
-
|
|
1286
|
-
h('
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
h('span', { className: 'n', textContent: `#${order.indexOf(t) + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
|
|
1292
|
-
h('span', { className: 'm', textContent: t.model }),
|
|
1293
|
-
h('span', { className: 'p', textContent: t.provider }),
|
|
1294
|
-
h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
|
|
1295
|
-
h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
|
|
1296
|
-
yes(c.tools), yes(c.thinking), yes(c.images),
|
|
1297
|
-
h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
|
|
1298
|
-
h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
|
|
1299
|
-
src(t),
|
|
1300
|
-
triedCell(t),
|
|
1301
|
-
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1302
|
-
}))))
|
|
1603
|
+
jobSlot('table'),
|
|
1604
|
+
byOutcome.length ? h('div', { className: 'mt-outcomes' }, h('span', { textContent: 'Test results', title: 'click one to select those models; ↻ tests them again' }), byOutcome) : null,
|
|
1605
|
+
h('div', { className: 'mt-h' }, h('span'),
|
|
1606
|
+
h('input', { type: 'checkbox', checked: all, indeterminate: PICKED.size > 0 && !all, title: all ? 'select none' : 'select all', 'aria-label': 'select all', onchange: () => (all ? PICKED.clear() : view.forEach((t) => PICKED.add(keyOf(t))), (lastPicked = null), drawMine(S)) }),
|
|
1607
|
+
h('span', { textContent: '#' }), head('model'), head('provider'), head('context', true), head('abilities', true), head('tools'), head('thinking'), head('images'), head('input', true), head('output', true), h('span', { textContent: 'From' }), head('works'), h('span')),
|
|
1608
|
+
table))
|
|
1303
1609
|
: h('div', { className: 'mt-empty' }, 'No models in your list yet. ', h('a', { href: '#providers', textContent: 'Choose some under Providers & models' }), '.'))
|
|
1304
|
-
|
|
1610
|
+
tableV?.draw()
|
|
1611
|
+
sortable(tableV, move)
|
|
1612
|
+
}
|
|
1613
|
+
// the table's cells: yes or no; where what it can do comes from
|
|
1614
|
+
const yes = (v) => h('span', { className: v ? 'yes' : 'no', textContent: v ? '✓' : '–' })
|
|
1615
|
+
const src = (t) => {
|
|
1616
|
+
const x = t.tested
|
|
1617
|
+
if (!x) return h('div', { className: 'src', textContent: 'looking it up…' })
|
|
1618
|
+
if (x.source === 'assumed') return h('div', { className: 'src warn', title: "models.dev doesn't know it: Retry, or set up what it can do" }, 'not found', retry(t), h('button', { className: 'small', textContent: 'Set up', onclick: () => openSetup(t) }))
|
|
1619
|
+
return h('div', { className: 'src' }, Object.keys(x.set ?? {}).length ? 'set by you' : x.source === 'models.dev' ? 'models.dev' : FROM[x.source], h('button', { className: 'small', textContent: 'Edit', onclick: () => openSetup(t) }))
|
|
1620
|
+
}
|
|
1621
|
+
// a row of the table: one model of your list, in the order shown (at: its place in your order)
|
|
1622
|
+
function tableRow(t, at, pick) {
|
|
1623
|
+
const c = can(t)
|
|
1624
|
+
const ctx = COLS.context.get(t)
|
|
1625
|
+
const picked = PICKED.has(keyOf(t))
|
|
1626
|
+
return h('li', { className: 'in' + (picked ? ' picked' : ''), 'data-key': `${t.provider}/${t.model}` },
|
|
1627
|
+
h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
|
|
1628
|
+
h('input', { type: 'checkbox', checked: picked, 'aria-label': `select ${t.model}`, title: 'select (Shift: and every row up to the last one selected)', onclick: (e) => (e.preventDefault(), pick(e)) }),
|
|
1629
|
+
h('span', { className: 'n', textContent: `#${at + 1}`, title: SORT.length ? 'its place in your order now' : '' }),
|
|
1630
|
+
h('span', { className: 'm', textContent: t.model, title: t.model }),
|
|
1631
|
+
h('span', { className: 'p', textContent: t.provider, title: t.provider }),
|
|
1632
|
+
h('span', { className: 'num', textContent: ctx ? `${t.tested?.sure === false ? '~' : ''}${kTok(ctx)}` : '–' }),
|
|
1633
|
+
h('span', { className: 'num', textContent: `${Object.values(c).filter(Boolean).length} of 3` }),
|
|
1634
|
+
yes(c.tools), yes(c.thinking), yes(c.images),
|
|
1635
|
+
h('span', { className: 'num', textContent: t.price?.input != null ? usd(t.price.input) : '–' }),
|
|
1636
|
+
h('span', { className: 'num', textContent: t.price?.output != null ? usd(t.price.output) : '–' }),
|
|
1637
|
+
src(t),
|
|
1638
|
+
triedCell(t),
|
|
1639
|
+
h('button', { className: 'danger', textContent: '✕', title: 'take it off the list', 'aria-label': `remove ${t.model}`, onclick: () => removeMine(t.provider, t.model) }))
|
|
1305
1640
|
}
|
|
1306
1641
|
|
|
1307
1642
|
// your list changed: the rows and counts it touches, in place
|
|
@@ -1444,11 +1779,15 @@ async function openReq(r) {
|
|
|
1444
1779
|
reqOpen = `${r.run}/${r.file}`
|
|
1445
1780
|
for (const b of $('l-list').querySelectorAll('.rq')) b.classList.toggle('on', b.dataset.key === reqOpen)
|
|
1446
1781
|
let d
|
|
1782
|
+
put($('l-detail-pane'), jobSlot('request'))
|
|
1783
|
+
job('request', { where: ['request'], what: 'Loading the request', detail: `${r.model ?? ''} · its messages and reply` })
|
|
1447
1784
|
try {
|
|
1448
1785
|
d = await api('/api/request', { run: r.run, file: r.file })
|
|
1449
1786
|
} catch (e) {
|
|
1787
|
+
job('request')
|
|
1450
1788
|
return put($('l-detail-pane'), h('div', { className: 'empty', textContent: `Can't read it: ${e.message}` }))
|
|
1451
1789
|
}
|
|
1790
|
+
job('request')
|
|
1452
1791
|
const row = (k, v) => (v === undefined || v === null || v === '' ? null : [h('dt', { textContent: k }), h('dd', { textContent: String(v) })])
|
|
1453
1792
|
const pre = (text) => h('pre', { textContent: typeof text === 'string' ? text : JSON.stringify(text, null, 2) })
|
|
1454
1793
|
const msgs = d.request?.messages ?? []
|
|
@@ -1605,6 +1944,7 @@ function drawFiles(s) {
|
|
|
1605
1944
|
|
|
1606
1945
|
function render(s) {
|
|
1607
1946
|
S = s
|
|
1947
|
+
for (const [key, r] of Object.entries(TRIES)) if (!r.going && placeOf(...key.split(/\/(.*)/s)) === undefined) delete TRIES[key] // off your list: its result too
|
|
1608
1948
|
drawProviders(s)
|
|
1609
1949
|
drawMine(s)
|
|
1610
1950
|
drawUsing(s)
|
|
@@ -1621,8 +1961,12 @@ showPage()
|
|
|
1621
1961
|
drawPresets()
|
|
1622
1962
|
if (!token) put($('page-providers'), h('h1', { textContent: 'Open the link HeartCode printed' }), h('p', { className: 'lead', textContent: 'It has the key to this page: /settings in HeartCode, or heart settings.' }))
|
|
1623
1963
|
else {
|
|
1624
|
-
|
|
1625
|
-
api('/api/
|
|
1964
|
+
job('state', { where: ['page'], what: 'Loading your settings', detail: 'providers, models, logging, prompts, MCP servers, skills' })
|
|
1965
|
+
api('/api/state').then(render, (e) => toast(e.message, true)).finally(() => job('state'))
|
|
1966
|
+
.then(() => api('/api/tests', {}))
|
|
1967
|
+
.then(({ results, run }) => (Object.assign(TRIES, results), showRun(run)), (e) => toast(`the tests so far: ${e.message}`, true)) // and one going
|
|
1968
|
+
job('requests', { where: ['requests'], what: 'Loading the request log', detail: 'every request HeartCode logged' })
|
|
1969
|
+
api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => ((REQS = []), drawReqs())).finally(() => job('requests'))
|
|
1626
1970
|
}
|
|
1627
1971
|
// A model's test, as it goes: the server streams each change; only its line in your list changes.
|
|
1628
1972
|
if (token) {
|
|
@@ -1638,13 +1982,29 @@ if (token) {
|
|
|
1638
1982
|
const failed = REQS.filter((x) => x.result === 'failed').length
|
|
1639
1983
|
$('n-failed').textContent = failed ? `${failed} failed` : ''
|
|
1640
1984
|
})
|
|
1985
|
+
// a test's result, and the run's progress (from the server, whichever page started it)
|
|
1986
|
+
events.addEventListener('tested', (e) => {
|
|
1987
|
+
const { key, result } = JSON.parse(e.data)
|
|
1988
|
+
gotResult(key, result)
|
|
1989
|
+
})
|
|
1990
|
+
events.addEventListener('testrun', (e) => showRun(JSON.parse(e.data)))
|
|
1991
|
+
// many at once (thousands of models added): taken in once a frame, the table drawn once
|
|
1992
|
+
const news = new Map()
|
|
1641
1993
|
events.addEventListener('test', (e) => {
|
|
1642
1994
|
const d = JSON.parse(e.data)
|
|
1643
|
-
|
|
1644
|
-
if (
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1995
|
+
news.set(d.key, d)
|
|
1996
|
+
if (news.size > 1) return
|
|
1997
|
+
requestAnimationFrame(() => {
|
|
1998
|
+
if (!S) return news.clear()
|
|
1999
|
+
for (const d of news.values()) {
|
|
2000
|
+
const t = S.order[placeOf(...d.key.split(/\/(.*)/s))]
|
|
2001
|
+
if (!t) continue
|
|
2002
|
+
Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
|
|
2003
|
+
document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
|
|
2004
|
+
}
|
|
2005
|
+
news.clear()
|
|
2006
|
+
drawMine(S)
|
|
2007
|
+
})
|
|
1648
2008
|
})
|
|
1649
2009
|
}
|
|
1650
2010
|
</script>
|