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/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; gap: 8px; align-items: center; }
154
- .cat-top input { flex: 1; min-width: 0; }
155
- .cat-top button { flex: none; }
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-wrap: anywhere; }
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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
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 30px minmax(0, 1.7fr) minmax(0, 0.6fr) minmax(0, 1.5fr) 26px; align-items: center; column-gap: 8px; padding: 7px 16px; }
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:first-child { background: var(--brand-soft); }
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: 1090px; }
253
- .mt-h, .mtable li { display: grid; grid-template-columns: 16px 28px 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; }
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-wrap: anywhere; }
269
- .mtable .p { color: var(--muted); overflow-wrap: anywhere; }
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"><input type="search" id="m-search" placeholder="Search all models…"><button class="small" id="fold-all" type="button">Collapse all</button><button class="small" id="unfold-all" type="button">Expand all</button></div>
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
- api('/api/models', { name }).then(({ models, details }) => (MODELS[name] = { list: models, details: details ?? {} }), (e) => (MODELS[name] = { error: e.message })).then(() => S && drawProviders(S))
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 = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
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', `✗ ${r.status ?? (/nothing/.test(r.error) ? 'empty' : /within/.test(r.error) ? 'no answer' : 'failed')}`, `doesn't work: ${r.error}${secs}`] // the reason on hover
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 (SORT.some((x) => x.key === 'works') && !TRIES[keyOf(t)]?.going) drawMine(S) // sorted by it: its place may change
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
- async function tryAll() {
848
- if (testingAll) return
849
- testingAll = true
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 Promise.all(Array.from({ length: Math.min(6, list.length) }, worker))
877
- } finally {
878
- testingAll = false
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
- toast(`${good} of ${list.length} work${good === list.length ? '' : `; ${list.length - good} don't (Works? says why, on hover)`}`, good < list.length)
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
- toast(t ? `looking up ${t.model} in models.dev…` : 'looking up your models in models.dev…')
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 ? h('ol', { className: 'olist' }, order.map((t, i) => h('li', {
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
- sortable($('inuse').querySelector('.olist'), move)
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]?.loading ? 'loading' : '')), modelQuery.trim()])
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
- function sortable(ol, onMove) {
1043
- if (!ol) return
1044
- ol.addEventListener('pointerdown', (e) => {
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 items = [...ol.querySelectorAll('li.in')]
1049
- if (items.length < 2) return
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
- const shift = () => items.forEach((x, k) => {
1058
- if (x === li) return
1059
- const by = from < to && k > from && k <= to ? -step : from > to && k >= to && k < from ? step : 0
1060
- x.style.transform = by ? `translateY(${by}px)` : ''
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
- const dy = ev.clientY - startY
1314
+ y = ev.clientY
1064
1315
  if (!moving) {
1065
- if (Math.abs(dy) < 4) return
1316
+ if (Math.abs(y - startY) < 4) return
1066
1317
  moving = true
1067
- li.setPointerCapture(e.pointerId)
1068
1318
  li.classList.add('lifted')
1069
- ol.classList.add('sorting')
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
- const y = Math.max(rects[0].top - rects[from].top, Math.min(rects.at(-1).bottom - rects[from].bottom, dy)) // within the list
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
- ol.classList.remove('sorting')
1335
+ box.classList.remove('sorting')
1087
1336
  li.classList.remove('lifted')
1088
- for (const x of items) {
1089
- x.style.transition = 'none' // into place at once: the cards are where they look
1090
- x.style.transform = ''
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
- const i = S.order.findIndex((t) => t.provider === provider && t.model === id)
1107
- return i < 0 ? undefined : i
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 row = $('p-list').querySelector(`.mrow[data-provider="${CSS.escape(provider)}"][data-model="${CSS.escape(id)}"]`)
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', textContent: 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 || ids.length ? h('div', { className: 'rows' }, typed, ids.map(item)) : null,
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, several at once (2 per provider at most), to see which really answer', onclick: () => tryAll() }) : null),
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
- h('div', { className: 'mt-h' }, h('span'), 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')),
1286
- h('ol', { className: 'mtable' + (order.length < 2 ? '' : ' drag') }, view.map((t) => {
1287
- const c = can(t)
1288
- const ctx = COLS.context.get(t)
1289
- return h('li', { className: 'in', 'data-key': `${t.provider}/${t.model}` },
1290
- h('span', { className: 'grip', textContent: '⋮⋮', 'aria-hidden': 'true' }),
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
- sortable($('mt').querySelector('.mtable'), move)
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
- api('/api/state').then(render, (e) => toast(e.message, true))
1625
- api('/api/requests').then(({ items }) => ((REQS = items), drawReqs()), () => ((REQS = []), drawReqs()))
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
- const t = S?.order.find((x) => `${x.provider}/${x.model}` === d.key)
1644
- if (!t) return
1645
- Object.assign(t, { tested: d.tested ?? t.tested, known: d.known ?? t.known })
1646
- document.querySelector(`#inuse li[data-key="${CSS.escape(d.key)}"] .test`)?.replaceWith(testLine(t))
1647
- drawMine(S)
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>