@opennodes/registry 0.1.24 → 0.1.26

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/web/index.html +88 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,6 +47,19 @@
47
47
  .mitem .t .nm { font-weight: 600; font-size: 0.86rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
48
  .mitem .b { display: flex; gap: 0.8rem; margin-top: 0.25rem; font-size: 0.7rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-wrap: wrap; }
49
49
  .mdetail { min-height: 30rem; }
50
+ .scorebox { margin: 0.4rem 0 0.8rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 0.5rem 0.75rem; font-size: 0.8rem; }
51
+ .scorebox summary { cursor: pointer; }
52
+ .scorebox table { width: 100%; border-collapse: collapse; margin-top: 0.4rem; }
53
+ .scorebox td { padding: 0.2rem 0.5rem 0.2rem 0; border-top: 1px solid var(--line-2); vertical-align: top; }
54
+ .scorebox td.num { text-align: right; white-space: nowrap; }
55
+ .scorebox td.sub { font-size: 0.74rem; }
56
+ .scorebox tr.tot td { border-top: 1px solid var(--line); }
57
+ .pager { display: flex; align-items: center; justify-content: center; gap: 0.3rem; margin-top: 0.5rem; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; }
58
+ .pager:empty { display: none; }
59
+ .pgbtn { font: inherit; min-width: 2rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--line); background: var(--panel); color: var(--accent); cursor: pointer; }
60
+ .pgbtn:disabled { color: var(--faint); cursor: default; }
61
+ .pgnum { padding: 0 0.3rem; }
62
+ .pgnum input { width: 4rem; font: inherit; padding: 0.2rem 0.3rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); text-align: center; }
50
63
 
51
64
  /* chat */
52
65
  .chatgrid { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.2rem; align-items: start; }
@@ -244,7 +257,7 @@
244
257
  <div id="adv-out"></div>
245
258
  </details>
246
259
  <div class="mgrid">
247
- <div class="mlist" id="m-list"></div>
260
+ <div class="mcol"><div class="mlist" id="m-list"></div><div class="pager" id="m-pager"></div></div>
248
261
  <div class="mdetail" id="m-detail"><p class="sub">Select a model from the list.</p></div>
249
262
  </div>
250
263
  </div>
@@ -261,6 +274,7 @@
261
274
  </div>
262
275
  <p class="desc" id="md-desc" hidden></p>
263
276
  <div class="statstrip" id="md-stats"></div>
277
+ <div id="md-score"></div>
264
278
  <div class="secnav" id="md-nav"></div>
265
279
  <h2 id="sec-offerings">Offerings <span class="counts" id="md-count"></span></h2>
266
280
  <div class="panel tablewrap" id="md-offerings"></div>
@@ -607,34 +621,47 @@ function provLine(o) {
607
621
  }
608
622
 
609
623
  const PAGE = 60;
610
- let listQuery = '', listTotal = 0;
611
-
612
- // Count line and the "Show more" button at the end of the list.
613
- function updateListFooter() {
614
- $('#m-more')?.remove();
615
- const shown = lastResults.length;
616
- $('#m-counts').textContent = fmtInt(listTotal) + ' offerings' + (shown < listTotal ? ` · showing 1–${fmtInt(shown)}` : '');
617
- if (shown < listTotal) {
618
- $('#m-list').insertAdjacentHTML('beforeend', `<button class="btn" id="m-more" style="background:var(--panel); color:var(--accent); align-self:stretch">Show ${fmtInt(Math.min(PAGE, listTotal - shown))} more</button>`);
619
- $('#m-more').addEventListener('click', loadMore);
620
- }
624
+ let listQuery = '', listTotal = 0, listPage = 0;
625
+
626
+ // Count line and page controls under the list: first / previous / page N of M / next / last.
627
+ function updatePager() {
628
+ const pages = Math.max(1, Math.ceil(listTotal / PAGE));
629
+ const from = listTotal ? listPage * PAGE + 1 : 0, to = Math.min(listTotal, (listPage + 1) * PAGE);
630
+ $('#m-counts').textContent = fmtInt(listTotal) + ' offerings' + (pages > 1 ? ` · ${fmtInt(from)}–${fmtInt(to)}` : '');
631
+ const pager = $('#m-pager');
632
+ if (pages <= 1) { pager.innerHTML = ''; return; }
633
+ const btn = (label, page, title) => `<button class="pgbtn" data-page="${page}" title="${title}"${page < 0 || page >= pages || page === listPage ? ' disabled' : ''}>${label}</button>`;
634
+ pager.innerHTML = btn('«', 0, 'first page') + btn('‹', listPage - 1, 'previous page')
635
+ + `<span class="pgnum">Page <input id="m-page" type="number" min="1" max="${pages}" value="${listPage + 1}" aria-label="page number"> of ${fmtInt(pages)}</span>`
636
+ + btn('›', listPage + 1, 'next page') + btn('»', pages - 1, 'last page');
637
+ pager.querySelectorAll('.pgbtn').forEach((b) => b.addEventListener('click', () => goToPage(Number(b.dataset.page))));
638
+ $('#m-page').addEventListener('change', (e) => goToPage(Math.min(pages, Math.max(1, Math.round(Number(e.target.value) || 1))) - 1));
621
639
  }
622
640
 
623
- async function loadMore() {
624
- const btn = $('#m-more');
625
- btn.disabled = true;
641
+ async function goToPage(page) {
626
642
  const query = listQuery;
627
643
  const p = new URLSearchParams(query);
628
- p.set('offset', lastResults.length);
644
+ p.set('offset', page * PAGE);
645
+ $('#m-pager').querySelectorAll('button, input').forEach((el) => { el.disabled = true; });
629
646
  try {
630
- const { offerings } = await api('/v0/offerings?' + p);
647
+ const { offerings, total } = await api('/v0/offerings?' + p);
631
648
  if (query !== listQuery) return; // the filters changed meanwhile
632
- const start = lastResults.length;
633
- lastResults = lastResults.concat(offerings);
634
- btn.insertAdjacentHTML('beforebegin', offerings.map(renderItem).join(''));
635
- [...document.querySelectorAll('#m-list .mitem')].slice(start).forEach((el) => el.addEventListener('click', () => selectModel(el)));
636
- updateListFooter();
637
- } catch (e) { btn.disabled = false; btn.textContent = 'Retry — ' + e.message; }
649
+ listPage = page;
650
+ listTotal = total ?? listTotal;
651
+ lastResults = offerings;
652
+ renderList(offerings);
653
+ $('#m-list').scrollTop = 0;
654
+ updatePager();
655
+ } catch (e) { updatePager(); $('#m-pager').insertAdjacentHTML('beforeend', '<span class="err">' + esc(e.message) + '</span>'); }
656
+ }
657
+
658
+ // Render one page of the list, keeping the highlighted model when it is on this page.
659
+ function renderList(offerings) {
660
+ $('#m-list').innerHTML = offerings.map(renderItem).join('')
661
+ || '<p class="sub">No offerings match. A fresh registry fills up via catalog imports and node registrations.</p>';
662
+ document.querySelectorAll('.mitem').forEach((el) => el.addEventListener('click', () => selectModel(el)));
663
+ const current = $('#model-card')?.dataset.artifact;
664
+ document.querySelectorAll('.mitem').forEach((el) => el.classList.toggle('sel', el.dataset.artifact === current));
638
665
  }
639
666
 
640
667
  async function search() {
@@ -652,17 +679,16 @@ async function search() {
652
679
  if (listQuery !== p.toString()) return; // a newer search is under way
653
680
  lastResults = offerings;
654
681
  listTotal = total ?? offerings.length;
655
- $('#m-list').innerHTML = offerings.map(renderItem).join('')
656
- || '<p class="sub">No offerings match. A fresh registry fills up via catalog imports and node registrations.</p>';
657
- updateListFooter();
658
- document.querySelectorAll('.mitem').forEach((el) => el.addEventListener('click', () => selectModel(el)));
682
+ listPage = 0;
683
+ renderList(offerings);
684
+ updatePager();
659
685
  // keep the current selection when still listed, else select the first result
660
686
  const current = $('#model-card')?.dataset.artifact;
661
687
  const keep = current && [...document.querySelectorAll('.mitem')].find((el) => el.dataset.artifact === current);
662
688
  const target = keep ?? document.querySelector('.mitem');
663
689
  if (target) selectModel(target);
664
690
  else $('#m-detail').innerHTML = '<p class="sub">No offerings match.</p>';
665
- } catch (e) { $('#m-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
691
+ } catch (e) { $('#m-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; $('#m-pager').innerHTML = ''; }
666
692
  }
667
693
 
668
694
  /* ---------- advisor (ONP-3 §5b) ---------- */
@@ -708,8 +734,10 @@ function renderAdvice(r) {
708
734
  });
709
735
  }
710
736
 
737
+ let listedOffering = null; // the list entry the detail was opened from: its score is explained there
711
738
  function selectModel(el) {
712
739
  document.querySelectorAll('.mitem').forEach((x) => x.classList.toggle('sel', x === el));
740
+ listedOffering = lastResults.find((o) => `${o.node_id}/${o.offering_id}` === el.dataset.key) ?? null;
713
741
  const host = $('#m-detail');
714
742
  if (!host.contains($('#model-card'))) { host.innerHTML = ''; host.appendChild($('#model-card')); }
715
743
  loadModel(el.dataset.artifact);
@@ -718,7 +746,7 @@ function selectModel(el) {
718
746
  let lastResults = [];
719
747
  function renderItem(o) {
720
748
  const obs = o.observed || {};
721
- return `<div class="mitem" data-artifact="${esc(o.model.artifact)}">
749
+ return `<div class="mitem" data-artifact="${esc(o.model.artifact)}" data-key="${esc(o.node_id + '/' + o.offering_id)}">
722
750
  <div class="t">
723
751
  <span class="nm">${esc(o.model.name)}</span>
724
752
  ${tierChip(o.tier)}
@@ -821,6 +849,35 @@ function renderRow(o, idx) {
821
849
  }
822
850
 
823
851
  /* ---------- model detail ---------- */
852
+ // How the list's ranking score for the selected entry was computed: weight × component per part.
853
+ function renderScore(artifact) {
854
+ const host = $('#md-score');
855
+ const o = listedOffering;
856
+ if (!o?.rank_explanation || o.model.artifact !== artifact) { host.innerHTML = ''; return; }
857
+ const { weights, components } = o.rank_explanation;
858
+ const obs = o.observed || {};
859
+ const why = {
860
+ price: o.pricing?.input_per_mtok == null ? 'no price published'
861
+ : `${o.pricing.input_per_mtok === 0 ? 'free' : '$' + fmt(o.pricing.input_per_mtok) + '/MTok in'} — position among the listed results (cheapest 1.00, dearest 0.00)`,
862
+ tier: {
863
+ verified: 'verified: passed identity, context and capacity checks',
864
+ community: 'community: passed the registration checks',
865
+ attested: 'attested by a third party',
866
+ }[o.tier] ?? (o.source === 'import' ? 'unverified: imported from a public catalog, not checked by this registry' : 'unverified: not checked yet'),
867
+ probe_success: obs.probes > 0 ? `${Math.round((obs.probe_success ?? 0) * obs.probes)} of the last ${obs.probes} checks passed` : 'no checks yet',
868
+ };
869
+ const label = { price: 'Price', tier: 'Trust tier', probe_success: 'Check pass rate' };
870
+ const rows = Object.keys(weights).map((k) => `<tr>
871
+ <td>${label[k] ?? esc(k)}</td>
872
+ <td class="num mono">${fmt(components[k] ?? 0)} × ${fmt(weights[k])}</td>
873
+ <td class="num mono"><b>${fmt((components[k] ?? 0) * weights[k])}</b></td>
874
+ <td class="sub">${esc(why[k] ?? '')}</td></tr>`).join('');
875
+ host.innerHTML = `<details class="scorebox" open>
876
+ <summary>Ranking score <b class="mono">${fmt(o.rank)}</b> <span class="sub">for ${esc(o.node_id)} in the current list</span></summary>
877
+ <table>${rows}<tr class="tot"><td>Total</td><td></td><td class="num mono"><b>${fmt(o.rank)}</b></td><td class="sub">weighted sum; measured values, never paid placement</td></tr></table>
878
+ </details>`;
879
+ }
880
+
824
881
  async function loadModel(artifact) {
825
882
  $('#model-card').dataset.artifact = artifact;
826
883
  $('#md-crumb').innerHTML = '<a href="#/">Models</a> / ' + esc(artifact);
@@ -874,6 +931,8 @@ async function loadModel(artifact) {
874
931
  first.modalities ? stat(esc((first.modalities.input || []).join('+')) + ' → ' + esc((first.modalities.output || []).join('+')), 'MODALITIES') : '',
875
932
  ].filter(Boolean).join('');
876
933
 
934
+ renderScore(artifact);
935
+
877
936
  // section nav (anchors appear only for sections that render)
878
937
  const navBits = [['#sec-offerings', 'OFFERINGS'], ['#sec-play', 'PLAYGROUND']];
879
938