@jacobbd/relay-ai 0.5.0 → 0.6.1

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.
@@ -7,7 +7,7 @@ import {
7
7
  listAddableTemplates,
8
8
  listSupportedTemplates,
9
9
  listVisibleOAuthTemplates
10
- } from "./chunk-MVBA7ABV.js";
10
+ } from "./chunk-EJONCU3B.js";
11
11
  init_provider_templates();
12
12
  export {
13
13
  PROVIDER_TEMPLATES,
@@ -17,4 +17,4 @@ export {
17
17
  listSupportedTemplates,
18
18
  listVisibleOAuthTemplates
19
19
  };
20
- //# sourceMappingURL=provider-templates-6XYKAZB5.js.map
20
+ //# sourceMappingURL=provider-templates-BPGB5V2L.js.map
@@ -3,7 +3,7 @@ import {
3
3
  getProviderModelPage,
4
4
  PROVIDER_MODEL_PAGE_SIZE,
5
5
  } from './provider-model-browser.js';
6
- import { copyDeviceCode, oauthConnectionLabel } from './oauth-device.js';
6
+ import { copyDeviceCode, copyTextToClipboard, oauthConnectionLabel } from './oauth-device.js';
7
7
  import { providerInitial, providerLogoHtml } from './provider-logo.js';
8
8
 
9
9
  // ─── State ───────────────────────────────────────────────────────────────────
@@ -53,6 +53,7 @@ const state = {
53
53
  listenMode: 'local', // 'local' | 'network'
54
54
  passwordMode: 'new', // 'saved' | 'new'
55
55
  password: '',
56
+ passwordVisible: false,
56
57
  savePassword: false,
57
58
  },
58
59
  },
@@ -207,7 +208,7 @@ async function initUpdateIndicator() {
207
208
 
208
209
  document.getElementById('update-copy-btn')?.addEventListener('click', async () => {
209
210
  try {
210
- await navigator.clipboard.writeText(UPDATE_COMMAND);
211
+ await copyTextToClipboard(UPDATE_COMMAND);
211
212
  showToast('Update command copied');
212
213
  } catch {
213
214
  showToast('Could not copy the update command');
@@ -1059,6 +1060,8 @@ function buildProviderBodyContent(provider) {
1059
1060
  const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
1060
1061
  if (countEl) countEl.textContent = `${result.count} models`;
1061
1062
  provider.modelCount = result.count;
1063
+ state.modelsLoaded = false;
1064
+ await initModels();
1062
1065
  } else {
1063
1066
  feedback.textContent = result.error ?? 'Refresh failed';
1064
1067
  feedback.className = 'key-feedback error';
@@ -1179,6 +1182,8 @@ function buildOAuthProviderBodyContent(provider) {
1179
1182
  feedback.className = 'key-feedback success';
1180
1183
  const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
1181
1184
  if (countEl) countEl.textContent = `${result.count} models`;
1185
+ state.modelsLoaded = false;
1186
+ await initModels();
1182
1187
  } else {
1183
1188
  feedback.textContent = result.error ?? 'Refresh failed';
1184
1189
  feedback.className = 'key-feedback error';
@@ -1519,8 +1524,15 @@ function updateAgyCounter() {
1519
1524
 
1520
1525
  // ─── Sidebar nav highlight ────────────────────────────────────────────────────
1521
1526
 
1527
+ function isServerAdminUi() {
1528
+ return document.documentElement.dataset.uiMode === 'server';
1529
+ }
1530
+
1522
1531
  function initNav() {
1523
- const sectionIds = ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1532
+ // Host-only sections stay in the DOM but are CSS-hidden in server admin mode.
1533
+ const sectionIds = isServerAdminUi()
1534
+ ? ['providers', 'favorites', 'server']
1535
+ : ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1524
1536
  const navItems = document.querySelectorAll('.nav-item');
1525
1537
  const content = document.getElementById('content');
1526
1538
 
@@ -1605,6 +1617,18 @@ async function initModels() {
1605
1617
  state.modelsError = String(err);
1606
1618
  state.modelsLoaded = true;
1607
1619
  }
1620
+ // Keep the Providers header counts in sync after add / delete / OAuth / refresh.
1621
+ renderProviderStats();
1622
+ // Server tab caches its provider checklist — invalidate so new providers appear without a full reload.
1623
+ invalidateServerProviderCache();
1624
+ }
1625
+
1626
+ function invalidateServerProviderCache() {
1627
+ state.server.form.providersLoaded = false;
1628
+ if (state.server.status?.running) return;
1629
+ if (state.server.form.expose !== 'specific') return;
1630
+ if (!document.getElementById('server-panel')) return;
1631
+ void loadServerProviders().then(() => renderServerPanel());
1608
1632
  }
1609
1633
 
1610
1634
  async function init() {
@@ -1616,11 +1640,13 @@ async function init() {
1616
1640
 
1617
1641
  await loadConfig();
1618
1642
  renderFavList();
1619
- renderAgyList();
1620
- updateAgyCounter();
1621
1643
  updateGeneralCounter();
1622
- await loadApps();
1623
- renderApps();
1644
+ if (!isServerAdminUi()) {
1645
+ renderAgyList();
1646
+ updateAgyCounter();
1647
+ await loadApps();
1648
+ renderApps();
1649
+ }
1624
1650
  renderProviders(); // shows skeletons
1625
1651
 
1626
1652
  initServerPolling();
@@ -1631,12 +1657,12 @@ async function init() {
1631
1657
  initModels().then(() => {
1632
1658
  renderProviders();
1633
1659
  renderProviderStats();
1634
- renderApps();
1660
+ if (!isServerAdminUi()) renderApps();
1635
1661
  // Re-render favorites now that we have full provider names
1636
1662
  renderFavList();
1637
- renderAgyList();
1663
+ if (!isServerAdminUi()) renderAgyList();
1638
1664
  if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1639
- if (state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1665
+ if (!isServerAdminUi() && state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1640
1666
  syncProviderModelBrowserFromHash();
1641
1667
  });
1642
1668
 
@@ -2220,15 +2246,28 @@ document.addEventListener('click', (e) => {
2220
2246
  async function refreshServerStatus() {
2221
2247
  try {
2222
2248
  const data = await api('GET', '/api/server/status');
2249
+ const wasRunning = Boolean(state.server.status?.running);
2223
2250
  state.server.status = data;
2224
2251
  if (!data.running) seedServerFormFromSaved(data.saved);
2252
+ const runningChanged = wasRunning !== Boolean(data.running);
2253
+ // Full innerHTML replace steals focus from password / search fields — skip while typing.
2254
+ if (runningChanged || !isEditingInside(document.getElementById('server-panel'))) {
2255
+ renderServerPanel();
2256
+ }
2225
2257
  } catch {
2226
2258
  // Transient poll failure — keep showing the last known state.
2227
2259
  }
2228
- renderServerPanel();
2229
2260
  updateServerNavBadge();
2230
2261
  }
2231
2262
 
2263
+ function isEditingInside(root) {
2264
+ if (!root) return false;
2265
+ const el = document.activeElement;
2266
+ if (!el || !root.contains(el)) return false;
2267
+ const tag = el.tagName;
2268
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
2269
+ }
2270
+
2232
2271
  function seedServerFormFromSaved(saved) {
2233
2272
  const f = state.server.form;
2234
2273
  if (f.seeded || !saved) return;
@@ -2240,8 +2279,16 @@ function seedServerFormFromSaved(saved) {
2240
2279
  f.freeModelsOnly = Boolean(saved.freeModelsOnly);
2241
2280
  f.exposedProviders = saved.exposedProviders ? [...saved.exposedProviders] : [];
2242
2281
  f.maskGatewayIds = saved.maskGatewayIds;
2243
- f.listenMode = saved.listenMode === 'network' ? 'network' : 'local';
2244
- f.passwordMode = saved.hasSavedPassword ? 'saved' : 'new';
2282
+ // Published Docker ports only reach 0.0.0.0 inside the container.
2283
+ f.listenMode = isServerAdminUi() || saved.listenMode === 'network' ? 'network' : 'local';
2284
+ // Env password (Docker): prefill the field so Start works and clients can copy it.
2285
+ // Keychain-saved password: keep "Use saved" (value never sent to the browser).
2286
+ if (saved.prefillPassword && !f.password) {
2287
+ f.password = saved.prefillPassword;
2288
+ f.passwordMode = 'new';
2289
+ } else {
2290
+ f.passwordMode = saved.hasSavedPassword ? 'saved' : 'new';
2291
+ }
2245
2292
  if (f.expose === 'specific') loadServerProviders().then(renderServerPanel);
2246
2293
  }
2247
2294
 
@@ -2282,6 +2329,10 @@ function renderServerPanel() {
2282
2329
  panel.innerHTML = '<div class="skeleton skeleton-card"></div>';
2283
2330
  return;
2284
2331
  }
2332
+ // Lazy-load (or re-load after invalidate) when the specific-provider checklist is visible.
2333
+ if (!s.status.running && s.form.expose === 'specific' && !s.form.providersLoaded) {
2334
+ void loadServerProviders().then(() => renderServerPanel());
2335
+ }
2285
2336
  panel.innerHTML = s.status.running ? renderServerRunning(s.status) : renderServerSetup(s);
2286
2337
  }
2287
2338
 
@@ -2341,16 +2392,22 @@ function renderServerNetworkOptions() {
2341
2392
  const s = state.server;
2342
2393
  const f = s.form;
2343
2394
  const hasSaved = Boolean(s.status?.saved?.hasSavedPassword);
2395
+ const hasEnv = Boolean(s.status?.saved?.hasEnvPassword);
2344
2396
  const showPasswordInput = f.passwordMode === 'new' || !hasSaved;
2397
+ const inputType = f.passwordVisible ? 'text' : 'password';
2345
2398
 
2346
2399
  return `
2347
2400
  <div class="server-network-options">
2348
- ${hasSaved ? toggleGroupHtml([
2401
+ ${hasSaved && !hasEnv ? toggleGroupHtml([
2349
2402
  { label: 'Use saved password', active: f.passwordMode === 'saved', onClick: "setServerPasswordMode('saved')" },
2350
2403
  { label: 'Enter new password', active: f.passwordMode === 'new', onClick: "setServerPasswordMode('new')" },
2351
2404
  ]) : ''}
2405
+ ${hasEnv && f.passwordMode === 'new' ? '<div class="server-field-hint">Prefilled from RELAY_AI_SERVER_PASSWORD</div>' : ''}
2352
2406
  ${showPasswordInput ? `
2353
- <input type="password" class="key-input" placeholder="Server password" value="${escapeHtml(f.password)}" oninput="setServerPassword(this.value)">
2407
+ <div class="server-password-row">
2408
+ <input type="${inputType}" class="key-input" id="server-password-input" placeholder="Server password" value="${escapeHtml(f.password)}" oninput="setServerPassword(this.value)" autocomplete="off">
2409
+ <button type="button" class="btn btn-ghost server-password-reveal" onclick="toggleServerPasswordVisible()" aria-pressed="${f.passwordVisible ? 'true' : 'false'}">${f.passwordVisible ? 'Hide' : 'Reveal'}</button>
2410
+ </div>
2354
2411
  <label class="server-toggle-row">
2355
2412
  <input type="checkbox" ${f.savePassword ? 'checked' : ''} onchange="setServerSavePassword(this.checked)">
2356
2413
  <span>Save this password for next time</span>
@@ -2360,6 +2417,17 @@ function renderServerNetworkOptions() {
2360
2417
  `;
2361
2418
  }
2362
2419
 
2420
+ function renderServerListenField(f) {
2421
+ if (isServerAdminUi()) {
2422
+ return `<div class="server-field-hint">Network (required in container — use the published host port from the URL cards)</div>${renderServerNetworkOptions()}`;
2423
+ }
2424
+ const toggle = toggleGroupHtml([
2425
+ { label: 'Local only', active: f.listenMode === 'local', onClick: "setServerListenMode('local')" },
2426
+ { label: 'Network', active: f.listenMode === 'network', onClick: "setServerListenMode('network')" },
2427
+ ]);
2428
+ return f.listenMode === 'network' ? `${toggle}${renderServerNetworkOptions()}` : toggle;
2429
+ }
2430
+
2363
2431
  function renderServerSetup(s) {
2364
2432
  const f = s.form;
2365
2433
  const specific = f.expose === 'specific';
@@ -2410,11 +2478,7 @@ function renderServerSetup(s) {
2410
2478
 
2411
2479
  <div class="server-field">
2412
2480
  <div class="server-field-label">Listen</div>
2413
- ${toggleGroupHtml([
2414
- { label: 'Local only', active: f.listenMode === 'local', onClick: "setServerListenMode('local')" },
2415
- { label: 'Network', active: f.listenMode === 'network', onClick: "setServerListenMode('network')" },
2416
- ])}
2417
- ${f.listenMode === 'network' ? renderServerNetworkOptions() : ''}
2481
+ ${renderServerListenField(f)}
2418
2482
  </div>
2419
2483
 
2420
2484
  ${s.error ? `<div class="key-feedback error server-start-error">${escapeHtml(s.error)}</div>` : ''}
@@ -2551,6 +2615,12 @@ function setServerPassword(value) {
2551
2615
  state.server.form.password = value;
2552
2616
  }
2553
2617
 
2618
+ function toggleServerPasswordVisible() {
2619
+ state.server.form.passwordVisible = !state.server.form.passwordVisible;
2620
+ renderServerPanel();
2621
+ document.getElementById('server-password-input')?.focus();
2622
+ }
2623
+
2554
2624
  function setServerSavePassword(checked) {
2555
2625
  state.server.form.savePassword = checked;
2556
2626
  }
@@ -2618,7 +2688,7 @@ function serverIdCell(id) {
2618
2688
 
2619
2689
  async function copyPlainText(text) {
2620
2690
  try {
2621
- await navigator.clipboard.writeText(text);
2691
+ await copyTextToClipboard(text);
2622
2692
  showToast('Copied to clipboard');
2623
2693
  } catch {
2624
2694
  showToast('Could not copy — copy manually');
@@ -2646,6 +2716,7 @@ window.setServerFreeModelsOnly = setServerFreeModelsOnly;
2646
2716
  window.setServerListenMode = setServerListenMode;
2647
2717
  window.setServerPasswordMode = setServerPasswordMode;
2648
2718
  window.setServerPassword = setServerPassword;
2719
+ window.toggleServerPasswordVisible = toggleServerPasswordVisible;
2649
2720
  window.setServerSavePassword = setServerSavePassword;
2650
2721
  window.submitServerStart = submitServerStart;
2651
2722
  window.stopServerGateway = stopServerGateway;
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-ui-mode="{{UI_MODE}}">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -1,30 +1,55 @@
1
- /** Return the short credential label used on a provider card. */
2
- export function oauthConnectionLabel(provider) {
3
- if (!provider?.hasKey) return provider?.freeAccess ? 'Free models' : 'Not configured';
4
- if (provider.authType !== 'oauth') return 'Key stored';
5
- const planLabel = provider.subscription?.label;
6
- return typeof planLabel === 'string' && planLabel.trim()
7
- ? `Connected · ${planLabel.trim()}`
8
- : 'Connected';
9
- }
1
+ /** Clipboard helpers for the web UI (works on http://LAN as well as https/localhost). */
10
2
 
11
- /** Copy a device code, with a small legacy fallback for older browser contexts. */
12
- export async function copyDeviceCode(code, clipboard = globalThis.navigator?.clipboard) {
13
- if (clipboard?.writeText) {
14
- await clipboard.writeText(code);
15
- return;
3
+ /**
4
+ * Copy text to the clipboard. Prefer the async Clipboard API in secure contexts;
5
+ * fall back to a hidden textarea + execCommand for plain HTTP (LAN Docker UI).
6
+ */
7
+ export async function copyTextToClipboard(
8
+ text,
9
+ clipboard = globalThis.navigator?.clipboard,
10
+ doc = globalThis.document,
11
+ ) {
12
+ const secure = typeof globalThis.isSecureContext === 'boolean'
13
+ ? globalThis.isSecureContext
14
+ : Boolean(clipboard?.writeText);
15
+
16
+ if (secure && clipboard?.writeText) {
17
+ try {
18
+ await clipboard.writeText(text);
19
+ return;
20
+ } catch {
21
+ // Fall through — common on http://192.168.x.x where the API exists but throws.
22
+ }
16
23
  }
17
- if (!globalThis.document?.body || typeof globalThis.document.execCommand !== 'function') {
24
+
25
+ if (!doc?.body || typeof doc.execCommand !== 'function') {
18
26
  throw new Error('Clipboard access is unavailable');
19
27
  }
20
- const input = globalThis.document.createElement('textarea');
21
- input.value = code;
28
+ const input = doc.createElement('textarea');
29
+ input.value = text;
22
30
  input.setAttribute('readonly', '');
23
31
  input.style.position = 'fixed';
32
+ input.style.left = '-9999px';
24
33
  input.style.opacity = '0';
25
- globalThis.document.body.appendChild(input);
34
+ doc.body.appendChild(input);
26
35
  input.select();
27
- const copied = globalThis.document.execCommand('copy');
36
+ input.setSelectionRange(0, input.value.length);
37
+ const copied = doc.execCommand('copy');
28
38
  input.remove();
29
39
  if (!copied) throw new Error('Copy failed');
30
40
  }
41
+
42
+ /** @deprecated Prefer copyTextToClipboard — kept for existing call sites / tests. */
43
+ export async function copyDeviceCode(code, clipboard = globalThis.navigator?.clipboard) {
44
+ return copyTextToClipboard(code, clipboard);
45
+ }
46
+
47
+ /** Return the short credential label used on a provider card. */
48
+ export function oauthConnectionLabel(provider) {
49
+ if (!provider?.hasKey) return provider?.freeAccess ? 'Free models' : 'Not configured';
50
+ if (provider.authType !== 'oauth') return 'Key stored';
51
+ const planLabel = provider.subscription?.label;
52
+ return typeof planLabel === 'string' && planLabel.trim()
53
+ ? `Connected · ${planLabel.trim()}`
54
+ : 'Connected';
55
+ }
@@ -1,4 +1,12 @@
1
1
  /* ─── Tokens ─────────────────────────────────────────────────────────────── */
2
+ /* Server admin UI (Docker): hide host-only sections before JS runs */
3
+ html[data-ui-mode="server"] .nav-item[data-section="apps"],
4
+ html[data-ui-mode="server"] .nav-item[data-section="antigravity"],
5
+ html[data-ui-mode="server"] #apps,
6
+ html[data-ui-mode="server"] #antigravity {
7
+ display: none !important;
8
+ }
9
+
2
10
  :root {
3
11
  /* Deep navy background — NOT flat black */
4
12
  --bg: oklch(12% 0.038 265);
@@ -1868,6 +1876,22 @@ input { font-family: inherit; }
1868
1876
  gap: 10px;
1869
1877
  }
1870
1878
 
1879
+ .server-password-row {
1880
+ display: flex;
1881
+ align-items: center;
1882
+ gap: 8px;
1883
+ }
1884
+
1885
+ .server-password-row .key-input {
1886
+ flex: 1;
1887
+ min-width: 0;
1888
+ }
1889
+
1890
+ .server-password-reveal {
1891
+ flex-shrink: 0;
1892
+ white-space: nowrap;
1893
+ }
1894
+
1871
1895
  .server-provider-chips {
1872
1896
  display: flex;
1873
1897
  flex-wrap: wrap;