@jacobbd/relay-ai 0.4.4 → 0.4.6

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.
@@ -1,3 +1,9 @@
1
+ import {
2
+ formatModelPrice,
3
+ getProviderModelPage,
4
+ PROVIDER_MODEL_PAGE_SIZE,
5
+ } from './provider-model-browser.js';
6
+
1
7
  // ─── State ───────────────────────────────────────────────────────────────────
2
8
 
3
9
  const AGY_MAX = 6;
@@ -13,6 +19,9 @@ const state = {
13
19
  modelsLoaded: false,
14
20
  modelsError: null,
15
21
  providerFilter: '',
22
+ activeProviderId: null,
23
+ providerModelFilter: '',
24
+ providerModelPage: 1,
16
25
  modelFilter: '',
17
26
  modelFreeOnly: false,
18
27
  agyFilter: '',
@@ -436,7 +445,7 @@ function buildProviderCard(provider) {
436
445
  header.className = 'provider-card-header';
437
446
  header.setAttribute('role', 'button');
438
447
  header.setAttribute('tabindex', '0');
439
- header.setAttribute('aria-expanded', 'false');
448
+ header.setAttribute('aria-label', `View models from ${displayName}`);
440
449
 
441
450
  const logoHtml = logo?.type === 'svg'
442
451
  ? logo.content
@@ -454,7 +463,9 @@ function buildProviderCard(provider) {
454
463
  <span class="status-chip ${provider.hasKey ? 'has-key' : provider.freeAccess ? 'free-access' : 'no-key'}">
455
464
  ${provider.hasKey ? 'Key stored' : provider.freeAccess ? 'Free models' : 'Not configured'}
456
465
  </span>
457
- <svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
466
+ <button class="provider-config-toggle" type="button" aria-label="Manage ${escapeHtml(displayName)} provider" aria-expanded="false" title="Manage provider">
467
+ <svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
468
+ </button>
458
469
  </div>
459
470
  `;
460
471
 
@@ -469,18 +480,151 @@ function buildProviderCard(provider) {
469
480
 
470
481
  function toggle() {
471
482
  const isOpen = card.classList.toggle('open');
472
- header.setAttribute('aria-expanded', String(isOpen));
483
+ configToggle.setAttribute('aria-expanded', String(isOpen));
473
484
  body.setAttribute('aria-hidden', String(!isOpen));
474
485
  }
475
486
 
476
- header.addEventListener('click', toggle);
477
- header.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
487
+ const configToggle = header.querySelector('.provider-config-toggle');
488
+ configToggle.addEventListener('click', event => { event.stopPropagation(); toggle(); });
489
+ header.addEventListener('click', () => openProviderModelBrowser(provider.id));
490
+ header.addEventListener('keydown', e => {
491
+ if (e.target === header && (e.key === 'Enter' || e.key === ' ')) {
492
+ e.preventDefault();
493
+ openProviderModelBrowser(provider.id);
494
+ }
495
+ });
478
496
 
479
497
  card.appendChild(header);
480
498
  card.appendChild(body);
481
499
  return card;
482
500
  }
483
501
 
502
+ function providerIdFromHash() {
503
+ const prefix = '#provider/';
504
+ if (!window.location.hash.startsWith(prefix)) return null;
505
+ try {
506
+ return decodeURIComponent(window.location.hash.slice(prefix.length));
507
+ } catch {
508
+ return null;
509
+ }
510
+ }
511
+
512
+ function openProviderModelBrowser(providerId) {
513
+ if (state.activeProviderId !== providerId) {
514
+ state.providerModelFilter = '';
515
+ state.providerModelPage = 1;
516
+ }
517
+ window.location.hash = `provider/${encodeURIComponent(providerId)}`;
518
+ }
519
+
520
+ function syncProviderModelBrowserFromHash() {
521
+ const providerId = providerIdFromHash();
522
+ const content = document.getElementById('content');
523
+ state.activeProviderId = providerId;
524
+ content.classList.toggle('provider-browser-open', Boolean(providerId));
525
+ if (!providerId) return;
526
+ document.querySelectorAll('.nav-item').forEach(item => {
527
+ item.classList.toggle('active', item.dataset.section === 'providers');
528
+ });
529
+ content.scrollTop = 0;
530
+ renderProviderModelBrowser();
531
+ }
532
+
533
+ function renderProviderModelBrowser() {
534
+ const container = document.getElementById('provider-model-browser');
535
+ if (!state.activeProviderId) return;
536
+
537
+ const provider = state.providers.find(item => item.id === state.activeProviderId);
538
+ if (!provider) {
539
+ container.innerHTML = state.modelsLoaded
540
+ ? '<div class="provider-browser-empty">Provider not found. <a href="#providers">Return to providers</a></div>'
541
+ : '<div class="provider-browser-empty">Loading models…</div>';
542
+ return;
543
+ }
544
+
545
+ const result = getProviderModelPage(provider.models ?? [], state.providerModelFilter, state.providerModelPage);
546
+ state.providerModelPage = result.page;
547
+ const first = result.total === 0 ? 0 : (result.page - 1) * PROVIDER_MODEL_PAGE_SIZE + 1;
548
+ const last = Math.min(result.page * PROVIDER_MODEL_PAGE_SIZE, result.total);
549
+ const [c1, c2] = providerPalette(provider.id);
550
+ const displayName = getProviderName(provider.id);
551
+
552
+ container.innerHTML = `
553
+ <a class="provider-browser-back" href="#providers">← Providers &amp; Keys</a>
554
+ <div class="provider-browser-hero">
555
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerInitial(displayName)}</div>
556
+ <div>
557
+ <div class="section-eyebrow">Provider catalog</div>
558
+ <h1 class="section-heading">${escapeHtml(displayName)} <span class="heading-accent">models</span></h1>
559
+ <p class="section-sub">${provider.models?.length ?? 0} models available · prices shown per 1M tokens</p>
560
+ </div>
561
+ </div>
562
+ <div class="provider-browser-tools">
563
+ <div class="search-field">
564
+ <svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
565
+ <input class="search-input" id="provider-model-search" type="search" value="${escapeHtml(state.providerModelFilter)}" placeholder="Search ${escapeHtml(displayName)} models…" aria-label="Search ${escapeHtml(displayName)} models">
566
+ </div>
567
+ <button class="btn btn-ghost" id="provider-model-refresh" type="button">Refresh</button>
568
+ </div>
569
+ <div class="provider-model-table-wrap">
570
+ <table class="provider-model-table">
571
+ <thead><tr><th>Model</th><th>Context</th><th>Price in / out</th></tr></thead>
572
+ <tbody>
573
+ ${result.items.map(model => `
574
+ <tr>
575
+ <td><strong>${escapeHtml(model.name || model.id)}</strong><code>${escapeHtml(model.id)}</code></td>
576
+ <td>${fmtCtx(model.contextWindow) || '—'}</td>
577
+ <td>${formatModelPrice(model.cost)}</td>
578
+ </tr>
579
+ `).join('') || '<tr><td colspan="3" class="provider-model-empty">No models match your search.</td></tr>'}
580
+ </tbody>
581
+ </table>
582
+ </div>
583
+ <div class="provider-model-pagination">
584
+ <span>${first}–${last} of ${result.total}</span>
585
+ <div>
586
+ <button class="btn btn-ghost" id="provider-model-prev" type="button" ${result.page <= 1 ? 'disabled' : ''}>Previous</button>
587
+ <span>Page ${result.page} of ${result.totalPages}</span>
588
+ <button class="btn btn-primary" id="provider-model-next" type="button" ${result.page >= result.totalPages ? 'disabled' : ''}>Next</button>
589
+ </div>
590
+ </div>
591
+ `;
592
+
593
+ document.getElementById('provider-model-search').addEventListener('input', event => {
594
+ state.providerModelFilter = event.target.value;
595
+ state.providerModelPage = 1;
596
+ renderProviderModelBrowser();
597
+ const search = document.getElementById('provider-model-search');
598
+ search?.focus();
599
+ search?.setSelectionRange(search.value.length, search.value.length);
600
+ });
601
+ document.getElementById('provider-model-prev').addEventListener('click', () => {
602
+ state.providerModelPage -= 1;
603
+ renderProviderModelBrowser();
604
+ });
605
+ document.getElementById('provider-model-next').addEventListener('click', () => {
606
+ state.providerModelPage += 1;
607
+ renderProviderModelBrowser();
608
+ });
609
+ document.getElementById('provider-model-refresh').addEventListener('click', async event => {
610
+ const button = event.currentTarget;
611
+ button.disabled = true;
612
+ button.textContent = 'Refreshing…';
613
+ const refreshed = await refreshProvider(provider.id);
614
+ if (!refreshed.ok) {
615
+ showToast(refreshed.error ?? 'Refresh failed');
616
+ button.disabled = false;
617
+ button.textContent = 'Refresh';
618
+ return;
619
+ }
620
+ await initModels();
621
+ renderProviders();
622
+ renderProviderStats();
623
+ renderProviderModelBrowser();
624
+ showToast(`${refreshed.count} models refreshed`);
625
+ });
626
+ }
627
+
484
628
  function buildTemplateCard(template) {
485
629
  const [c1, c2] = providerPalette(template.id);
486
630
  const initial = providerInitial(template.name);
@@ -1404,6 +1548,7 @@ function initNav() {
1404
1548
  setActive('providers');
1405
1549
 
1406
1550
  content.addEventListener('scroll', () => {
1551
+ if (content.classList.contains('provider-browser-open')) return;
1407
1552
  const contentTop = content.getBoundingClientRect().top;
1408
1553
  const threshold = content.clientHeight * 0.4;
1409
1554
  let activeId = 'providers';
@@ -1508,8 +1653,12 @@ async function init() {
1508
1653
  renderAgyList();
1509
1654
  if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1510
1655
  if (state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1656
+ syncProviderModelBrowserFromHash();
1511
1657
  });
1512
1658
 
1659
+ window.addEventListener('hashchange', syncProviderModelBrowserFromHash);
1660
+ syncProviderModelBrowserFromHash();
1661
+
1513
1662
  document.getElementById('provider-search').addEventListener('input', e => {
1514
1663
  state.providerFilter = e.target.value;
1515
1664
  renderProviders();
@@ -15,7 +15,13 @@
15
15
 
16
16
  <!-- Sidebar -->
17
17
  <aside class="sidebar">
18
- <div class="sidebar-brand">
18
+ <a
19
+ class="sidebar-brand"
20
+ href="https://github.com/jacob-bd/relay-ai"
21
+ target="_blank"
22
+ rel="noopener noreferrer"
23
+ aria-label="Open the relay-ai GitHub repository"
24
+ >
19
25
  <div class="brand-logo">
20
26
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
21
27
  <defs>
@@ -42,7 +48,7 @@
42
48
  <div class="brand-name">relay<span class="brand-accent">-ai</span></div>
43
49
  <div class="brand-byline">by jacob-bd</div>
44
50
  </div>
45
- </div>
51
+ </a>
46
52
 
47
53
  <div class="sidebar-version-area" id="sidebar-version-area">
48
54
  <div class="sidebar-version">v{{VERSION}}</div>
@@ -236,6 +242,8 @@
236
242
  <div id="server-panel"></div>
237
243
  </section>
238
244
 
245
+ <section id="provider-model-browser" class="section provider-model-browser" aria-live="polite"></section>
246
+
239
247
  </main>
240
248
  </div>
241
249
 
@@ -0,0 +1,29 @@
1
+ export const PROVIDER_MODEL_PAGE_SIZE = 25;
2
+
3
+ export function filterProviderModels(models, query) {
4
+ const needle = query.trim().toLowerCase();
5
+ if (!needle) return models;
6
+ return models.filter(model =>
7
+ model.id.toLowerCase().includes(needle)
8
+ || (model.name ?? '').toLowerCase().includes(needle),
9
+ );
10
+ }
11
+
12
+ export function getProviderModelPage(models, query, requestedPage) {
13
+ const filtered = filterProviderModels(models, query);
14
+ const totalPages = Math.max(1, Math.ceil(filtered.length / PROVIDER_MODEL_PAGE_SIZE));
15
+ const page = Math.min(Math.max(1, requestedPage), totalPages);
16
+ const start = (page - 1) * PROVIDER_MODEL_PAGE_SIZE;
17
+ return {
18
+ items: filtered.slice(start, start + PROVIDER_MODEL_PAGE_SIZE),
19
+ page,
20
+ total: filtered.length,
21
+ totalPages,
22
+ };
23
+ }
24
+
25
+ export function formatModelPrice(cost) {
26
+ if (!cost || !Number.isFinite(cost.input) || !Number.isFinite(cost.output)) return '—';
27
+ const format = value => `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`;
28
+ return `${format(cost.input)} / ${format(cost.output)}`;
29
+ }
@@ -125,8 +125,14 @@ input { font-family: inherit; }
125
125
  align-items: center;
126
126
  gap: 12px;
127
127
  padding: 28px 24px 20px;
128
+ color: inherit;
129
+ text-decoration: none;
130
+ transition: background var(--dur-sm) var(--ease);
128
131
  }
129
132
 
133
+ .sidebar-brand:hover { background: var(--surface-hover); }
134
+ .sidebar-brand:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
135
+
130
136
  .brand-logo {
131
137
  width: 46px;
132
138
  height: 46px;
@@ -928,6 +934,21 @@ input { font-family: inherit; }
928
934
  border-bottom-right-radius: calc(var(--radius) - 1px);
929
935
  }
930
936
 
937
+ .provider-config-toggle {
938
+ display: grid;
939
+ place-items: center;
940
+ width: 34px;
941
+ height: 34px;
942
+ border-radius: var(--radius-sm);
943
+ color: var(--text-3);
944
+ }
945
+
946
+ .provider-config-toggle:hover,
947
+ .provider-config-toggle:focus-visible {
948
+ background: var(--accent-muted);
949
+ color: var(--accent);
950
+ }
951
+
931
952
  .provider-card-header:hover { background: var(--surface-hover); }
932
953
  .provider-card.open .provider-card-header {
933
954
  background: var(--surface-2);
@@ -1039,6 +1060,99 @@ input { font-family: inherit; }
1039
1060
  color: var(--accent);
1040
1061
  }
1041
1062
 
1063
+ /* Provider model browser */
1064
+ .provider-model-browser { display: none; max-width: 960px; }
1065
+ .content.provider-browser-open > .section { display: none; }
1066
+ .content.provider-browser-open > .provider-model-browser { display: block; margin: 0; border: 0; }
1067
+
1068
+ .provider-browser-back {
1069
+ display: inline-flex;
1070
+ margin-bottom: 26px;
1071
+ color: var(--text-2);
1072
+ font-size: 14px;
1073
+ font-weight: 600;
1074
+ }
1075
+
1076
+ .provider-browser-back:hover { color: var(--accent); }
1077
+
1078
+ .provider-browser-hero {
1079
+ display: flex;
1080
+ align-items: center;
1081
+ gap: 16px;
1082
+ margin-bottom: 28px;
1083
+ }
1084
+
1085
+ .provider-browser-hero .section-eyebrow { margin-bottom: 3px; }
1086
+ .provider-browser-hero .section-heading { margin-bottom: 4px; }
1087
+
1088
+ .provider-browser-tools {
1089
+ display: grid;
1090
+ grid-template-columns: minmax(0, 1fr) auto;
1091
+ gap: 10px;
1092
+ margin-bottom: 14px;
1093
+ }
1094
+
1095
+ .provider-model-table-wrap {
1096
+ overflow-x: auto;
1097
+ border: 1px solid var(--border);
1098
+ border-radius: var(--radius);
1099
+ background: var(--surface);
1100
+ }
1101
+
1102
+ .provider-model-table {
1103
+ width: 100%;
1104
+ border-collapse: collapse;
1105
+ font-size: 14px;
1106
+ }
1107
+
1108
+ .provider-model-table th {
1109
+ padding: 11px 16px;
1110
+ text-align: left;
1111
+ color: var(--text-3);
1112
+ background: var(--surface-2);
1113
+ border-bottom: 1px solid var(--border);
1114
+ font-size: 12px;
1115
+ font-weight: 700;
1116
+ letter-spacing: 0.05em;
1117
+ text-transform: uppercase;
1118
+ }
1119
+
1120
+ .provider-model-table th:nth-child(2) { width: 120px; }
1121
+ .provider-model-table th:nth-child(3) { width: 170px; }
1122
+
1123
+ .provider-model-table td {
1124
+ padding: 12px 16px;
1125
+ color: var(--text-2);
1126
+ border-bottom: 1px solid var(--border);
1127
+ vertical-align: middle;
1128
+ }
1129
+
1130
+ .provider-model-table tr:last-child td { border-bottom: 0; }
1131
+ .provider-model-table tbody tr:hover { background: var(--surface-hover); }
1132
+ .provider-model-table strong { display: block; color: var(--text-1); font-size: 14px; }
1133
+ .provider-model-table code { display: block; margin-top: 3px; color: var(--text-3); font-size: 12px; }
1134
+ .provider-model-table .provider-model-empty { padding: 34px 16px; text-align: center; color: var(--text-3); }
1135
+
1136
+ .provider-model-pagination {
1137
+ display: flex;
1138
+ align-items: center;
1139
+ justify-content: space-between;
1140
+ gap: 16px;
1141
+ padding-top: 14px;
1142
+ color: var(--text-3);
1143
+ font-size: 13px;
1144
+ }
1145
+
1146
+ .provider-model-pagination > div { display: flex; align-items: center; gap: 10px; }
1147
+ .provider-browser-empty { padding-top: 52px; color: var(--text-2); }
1148
+
1149
+ @media (max-width: 760px) {
1150
+ .provider-browser-tools { grid-template-columns: 1fr; }
1151
+ .provider-model-pagination { align-items: flex-start; flex-direction: column; }
1152
+ .provider-model-table th:nth-child(2) { width: 90px; }
1153
+ .provider-model-table th:nth-child(3) { width: 140px; }
1154
+ }
1155
+
1042
1156
  /* Provider body expand */
1043
1157
  .provider-body {
1044
1158
  display: grid;
@@ -65,7 +65,7 @@ import {
65
65
  summarizeServerProviders,
66
66
  validateCustomEndpointUrl,
67
67
  writeSecureLogLine
68
- } from "./chunk-XCB2K4GI.js";
68
+ } from "./chunk-YM6G7BHE.js";
69
69
  import {
70
70
  __toCommonJS,
71
71
  init_provider_templates,
@@ -1453,4 +1453,4 @@ export {
1453
1453
  resolveUiShutdownDecision,
1454
1454
  runUiCommand
1455
1455
  };
1456
- //# sourceMappingURL=ui-command-JG3BFVSL.js.map
1456
+ //# sourceMappingURL=ui-command-2HNQX7U3.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacobbd/relay-ai",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },