@jacobbd/relay-ai 0.4.9 → 0.6.0

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-DO5FAMNC.js";
10
+ } from "./chunk-MVBA7ABV.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-LYGIFRRT.js.map
20
+ //# sourceMappingURL=provider-templates-6XYKAZB5.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 ───────────────────────────────────────────────────────────────────
@@ -207,7 +207,7 @@ async function initUpdateIndicator() {
207
207
 
208
208
  document.getElementById('update-copy-btn')?.addEventListener('click', async () => {
209
209
  try {
210
- await navigator.clipboard.writeText(UPDATE_COMMAND);
210
+ await copyTextToClipboard(UPDATE_COMMAND);
211
211
  showToast('Update command copied');
212
212
  } catch {
213
213
  showToast('Could not copy the update command');
@@ -1519,8 +1519,15 @@ function updateAgyCounter() {
1519
1519
 
1520
1520
  // ─── Sidebar nav highlight ────────────────────────────────────────────────────
1521
1521
 
1522
+ function isServerAdminUi() {
1523
+ return document.documentElement.dataset.uiMode === 'server';
1524
+ }
1525
+
1522
1526
  function initNav() {
1523
- const sectionIds = ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1527
+ // Host-only sections stay in the DOM but are CSS-hidden in server admin mode.
1528
+ const sectionIds = isServerAdminUi()
1529
+ ? ['providers', 'favorites', 'server']
1530
+ : ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1524
1531
  const navItems = document.querySelectorAll('.nav-item');
1525
1532
  const content = document.getElementById('content');
1526
1533
 
@@ -1616,11 +1623,13 @@ async function init() {
1616
1623
 
1617
1624
  await loadConfig();
1618
1625
  renderFavList();
1619
- renderAgyList();
1620
- updateAgyCounter();
1621
1626
  updateGeneralCounter();
1622
- await loadApps();
1623
- renderApps();
1627
+ if (!isServerAdminUi()) {
1628
+ renderAgyList();
1629
+ updateAgyCounter();
1630
+ await loadApps();
1631
+ renderApps();
1632
+ }
1624
1633
  renderProviders(); // shows skeletons
1625
1634
 
1626
1635
  initServerPolling();
@@ -1631,12 +1640,12 @@ async function init() {
1631
1640
  initModels().then(() => {
1632
1641
  renderProviders();
1633
1642
  renderProviderStats();
1634
- renderApps();
1643
+ if (!isServerAdminUi()) renderApps();
1635
1644
  // Re-render favorites now that we have full provider names
1636
1645
  renderFavList();
1637
- renderAgyList();
1646
+ if (!isServerAdminUi()) renderAgyList();
1638
1647
  if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1639
- if (state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1648
+ if (!isServerAdminUi() && state.agyFilter) buildModelResults(state.agyFilter, 'agy');
1640
1649
  syncProviderModelBrowserFromHash();
1641
1650
  });
1642
1651
 
@@ -2220,15 +2229,28 @@ document.addEventListener('click', (e) => {
2220
2229
  async function refreshServerStatus() {
2221
2230
  try {
2222
2231
  const data = await api('GET', '/api/server/status');
2232
+ const wasRunning = Boolean(state.server.status?.running);
2223
2233
  state.server.status = data;
2224
2234
  if (!data.running) seedServerFormFromSaved(data.saved);
2235
+ const runningChanged = wasRunning !== Boolean(data.running);
2236
+ // Full innerHTML replace steals focus from password / search fields — skip while typing.
2237
+ if (runningChanged || !isEditingInside(document.getElementById('server-panel'))) {
2238
+ renderServerPanel();
2239
+ }
2225
2240
  } catch {
2226
2241
  // Transient poll failure — keep showing the last known state.
2227
2242
  }
2228
- renderServerPanel();
2229
2243
  updateServerNavBadge();
2230
2244
  }
2231
2245
 
2246
+ function isEditingInside(root) {
2247
+ if (!root) return false;
2248
+ const el = document.activeElement;
2249
+ if (!el || !root.contains(el)) return false;
2250
+ const tag = el.tagName;
2251
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
2252
+ }
2253
+
2232
2254
  function seedServerFormFromSaved(saved) {
2233
2255
  const f = state.server.form;
2234
2256
  if (f.seeded || !saved) return;
@@ -2240,7 +2262,8 @@ function seedServerFormFromSaved(saved) {
2240
2262
  f.freeModelsOnly = Boolean(saved.freeModelsOnly);
2241
2263
  f.exposedProviders = saved.exposedProviders ? [...saved.exposedProviders] : [];
2242
2264
  f.maskGatewayIds = saved.maskGatewayIds;
2243
- f.listenMode = saved.listenMode === 'network' ? 'network' : 'local';
2265
+ // Published Docker ports only reach 0.0.0.0 inside the container.
2266
+ f.listenMode = isServerAdminUi() || saved.listenMode === 'network' ? 'network' : 'local';
2244
2267
  f.passwordMode = saved.hasSavedPassword ? 'saved' : 'new';
2245
2268
  if (f.expose === 'specific') loadServerProviders().then(renderServerPanel);
2246
2269
  }
@@ -2360,6 +2383,17 @@ function renderServerNetworkOptions() {
2360
2383
  `;
2361
2384
  }
2362
2385
 
2386
+ function renderServerListenField(f) {
2387
+ if (isServerAdminUi()) {
2388
+ return `<div class="server-field-hint">Network (required in container — use the published host port from the URL cards)</div>${renderServerNetworkOptions()}`;
2389
+ }
2390
+ const toggle = toggleGroupHtml([
2391
+ { label: 'Local only', active: f.listenMode === 'local', onClick: "setServerListenMode('local')" },
2392
+ { label: 'Network', active: f.listenMode === 'network', onClick: "setServerListenMode('network')" },
2393
+ ]);
2394
+ return f.listenMode === 'network' ? `${toggle}${renderServerNetworkOptions()}` : toggle;
2395
+ }
2396
+
2363
2397
  function renderServerSetup(s) {
2364
2398
  const f = s.form;
2365
2399
  const specific = f.expose === 'specific';
@@ -2410,11 +2444,7 @@ function renderServerSetup(s) {
2410
2444
 
2411
2445
  <div class="server-field">
2412
2446
  <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() : ''}
2447
+ ${renderServerListenField(f)}
2418
2448
  </div>
2419
2449
 
2420
2450
  ${s.error ? `<div class="key-feedback error server-start-error">${escapeHtml(s.error)}</div>` : ''}
@@ -2618,7 +2648,7 @@ function serverIdCell(id) {
2618
2648
 
2619
2649
  async function copyPlainText(text) {
2620
2650
  try {
2621
- await navigator.clipboard.writeText(text);
2651
+ await copyTextToClipboard(text);
2622
2652
  showToast('Copied to clipboard');
2623
2653
  } catch {
2624
2654
  showToast('Could not copy — copy manually');
@@ -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
+ }
@@ -2,9 +2,13 @@
2
2
  // Simple Icons URLs were verified when added; inline SVGs cover brands whose
3
3
  // official assets cannot be hotlinked reliably.
4
4
 
5
+ const QWEN_CLOUD_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38 140" style="width:22px;height:22px"><title>Qwen Cloud</title><path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/></svg>`;
6
+
5
7
  const PROVIDER_INLINE_SVGS = {
6
8
  openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
7
9
  kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
10
+ 'qwen-cloud-token-plan': QWEN_CLOUD_SVG,
11
+ 'qwen-cloud-payg': QWEN_CLOUD_SVG,
8
12
  xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
9
13
  };
10
14
 
@@ -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);
@@ -11,6 +11,7 @@ import {
11
11
  completeAntigravityExchange,
12
12
  copilotPlanTier,
13
13
  createGatewayModelCatalog,
14
+ ensureOpencodeCloudProviders,
14
15
  favoriteProviderDisplayName,
15
16
  fetchProviderCatalog,
16
17
  filterServerModelsByFavorites,
@@ -19,11 +20,11 @@ import {
19
20
  findBinaryOnPath,
20
21
  findClaudeApp,
21
22
  findCodexApp,
23
+ formatGatewayUrls,
22
24
  freeStatusLabel,
23
25
  gatewayProviderLabel,
24
26
  getAppHome,
25
27
  getAppPathOverride,
26
- getLocalIps,
27
28
  getSavedServerPassword,
28
29
  getServerExposedProviders,
29
30
  getServerFavoritesOnly,
@@ -32,6 +33,7 @@ import {
32
33
  getServerMaskGatewayIds,
33
34
  getUiDebugLogPath,
34
35
  guiCallbackRedirectUri,
36
+ hostFromHeader,
35
37
  loadPreferences,
36
38
  loadRegistry,
37
39
  loadServerModels,
@@ -49,6 +51,8 @@ import {
49
51
  requestGithubDeviceCode,
50
52
  requestOpenAiDeviceCode,
51
53
  requestXaiDeviceCode,
54
+ resolveAdvertiseAddresses,
55
+ resolveAdvertiseGatewayPort,
52
56
  resolveProviderCredential,
53
57
  resolveServerUpstreamApiKey,
54
58
  saveNativeOAuthCredential,
@@ -67,14 +71,14 @@ import {
67
71
  supportsClaudeTransparentMode,
68
72
  validateCustomEndpointUrl,
69
73
  writeSecureLogLine
70
- } from "./chunk-XTBJCRT3.js";
74
+ } from "./chunk-SV2Y6OCD.js";
71
75
  import {
72
76
  __toCommonJS,
73
77
  init_provider_templates,
74
78
  listAddableTemplates,
75
79
  listVisibleOAuthTemplates,
76
80
  provider_templates_exports
77
- } from "./chunk-DO5FAMNC.js";
81
+ } from "./chunk-MVBA7ABV.js";
78
82
 
79
83
  // src/ui-command.ts
80
84
  import { createServer } from "http";
@@ -390,16 +394,18 @@ async function buildSavedConfig() {
390
394
  hasSavedPassword: await hasSavedPasswordCached()
391
395
  };
392
396
  }
393
- async function getServerStatus() {
397
+ async function getServerStatus(opts) {
394
398
  const saved = await buildSavedConfig();
395
399
  if (!running) return { running: false, saved };
396
400
  const { handle, config, serverPassword, providerSummary, modelRows } = running;
401
+ const publicPort = resolveAdvertiseGatewayPort(handle.port);
402
+ const loopback = formatGatewayUrls("127.0.0.1", publicPort);
397
403
  const payload = {
398
404
  running: true,
399
405
  saved,
400
406
  listenMode: config.listenMode,
401
- anthropicUrl: `http://127.0.0.1:${handle.port}/anthropic`,
402
- openaiUrl: `http://127.0.0.1:${handle.port}/openai/v1`,
407
+ anthropicUrl: loopback.anthropicUrl,
408
+ openaiUrl: loopback.openaiUrl,
403
409
  exposedProviders: config.exposedProviders,
404
410
  favoritesOnly: config.favoritesOnly,
405
411
  freeModelsOnly: config.freeModelsOnly,
@@ -408,26 +414,27 @@ async function getServerStatus() {
408
414
  models: modelRows
409
415
  };
410
416
  if (config.listenMode === "network") {
411
- payload.networkUrls = getLocalIps().map(({ name, address }) => ({
412
- name,
413
- anthropicUrl: `http://${address}:${handle.port}/anthropic`,
414
- openaiUrl: `http://${address}:${handle.port}/openai/v1`
415
- }));
417
+ payload.networkUrls = resolveAdvertiseAddresses({ requestHost: opts?.requestHost }).map(
418
+ ({ name, address }) => {
419
+ const urls = formatGatewayUrls(address, publicPort);
420
+ return { name, anthropicUrl: urls.anthropicUrl, openaiUrl: urls.openaiUrl };
421
+ }
422
+ );
416
423
  payload.apiKey = serverPassword ?? void 0;
417
424
  } else {
418
425
  payload.apiKey = "any non-empty value";
419
426
  }
420
427
  return payload;
421
428
  }
422
- function startGatewayServer(req) {
429
+ function startGatewayServer(req, opts) {
423
430
  if (running) return Promise.resolve({ ok: false, error: "Server is already running. Stop it first." });
424
431
  if (startInFlight) return startInFlight;
425
- startInFlight = doStartGatewayServer(req).finally(() => {
432
+ startInFlight = doStartGatewayServer(req, opts).finally(() => {
426
433
  startInFlight = null;
427
434
  });
428
435
  return startInFlight;
429
436
  }
430
- async function doStartGatewayServer(req) {
437
+ async function doStartGatewayServer(req, opts) {
431
438
  if (req.listenMode !== "local" && req.listenMode !== "network") {
432
439
  return { ok: false, error: "Invalid listen mode." };
433
440
  }
@@ -515,7 +522,7 @@ async function doStartGatewayServer(req) {
515
522
  providerSummary: summarizeServerProviders(models),
516
523
  modelRows: buildModelRows(models, gateway)
517
524
  };
518
- return { ok: true, status: await getServerStatus() };
525
+ return { ok: true, status: await getServerStatus({ requestHost: opts?.requestHost }) };
519
526
  }
520
527
  async function stopGatewayServer() {
521
528
  if (running) {
@@ -605,16 +612,18 @@ function handleUiApiRequest(req, res, opts = {}) {
605
612
  handleOAuthStatus(req, res);
606
613
  } else if (url.startsWith("/oauth/callback") && req.method === "GET") {
607
614
  handleOAuthCallback(req, res);
608
- } else if (url === "/api/apps" && req.method === "GET") {
609
- handleGetApps(res);
610
- } else if (url === "/api/apps/path" && req.method === "POST") {
611
- handleSetAppPath(req, res);
612
- } else if (url === "/api/apps/launch" && req.method === "POST") {
613
- handleLaunchApp(req, res, opts);
614
- } else if (url === "/api/apps/browse-folder" && req.method === "POST") {
615
- handleBrowseFolder(res);
615
+ } else if (url.startsWith("/api/apps")) {
616
+ if (opts.uiMode === "server") {
617
+ sendJson(res, 403, { error: "App launch is unavailable in server admin UI mode." });
618
+ return;
619
+ }
620
+ if (url === "/api/apps" && req.method === "GET") handleGetApps(res);
621
+ else if (url === "/api/apps/path" && req.method === "POST") handleSetAppPath(req, res);
622
+ else if (url === "/api/apps/launch" && req.method === "POST") handleLaunchApp(req, res, opts);
623
+ else if (url === "/api/apps/browse-folder" && req.method === "POST") handleBrowseFolder(res);
624
+ else sendJson(res, 404, { error: "Not found" });
616
625
  } else if (url === "/api/server/status" && req.method === "GET") {
617
- handleGetServerStatus(res);
626
+ handleGetServerStatus(req, res);
618
627
  } else if (url === "/api/server/providers" && req.method === "GET") {
619
628
  handleGetServerProviders(res);
620
629
  } else if (url === "/api/server/start" && req.method === "POST") {
@@ -723,7 +732,7 @@ async function handlePostKeys(req, res) {
723
732
  if (saved) {
724
733
  sendJson(res, 200, { ok: true });
725
734
  } else {
726
- sendJson(res, 500, { error: "Keychain unavailable \u2014 key not saved" });
735
+ sendJson(res, 500, { error: "Credential store unavailable \u2014 key not saved" });
727
736
  }
728
737
  } catch (err) {
729
738
  sendJson(res, 400, { error: String(err) });
@@ -798,7 +807,7 @@ async function handleAddProvider(req, res) {
798
807
  sendJson(res, 400, { error: "templateId required" });
799
808
  return;
800
809
  }
801
- const { listSupportedTemplates } = await import("./provider-templates-LYGIFRRT.js");
810
+ const { listSupportedTemplates } = await import("./provider-templates-6XYKAZB5.js");
802
811
  const template = listSupportedTemplates().find((t) => t.id === templateId);
803
812
  if (!template) {
804
813
  sendJson(res, 404, { error: `Template '${templateId}' not found` });
@@ -1199,9 +1208,9 @@ async function handleSetAppPath(req, res) {
1199
1208
  sendJson(res, 500, { error: String(err) });
1200
1209
  }
1201
1210
  }
1202
- async function handleGetServerStatus(res) {
1211
+ async function handleGetServerStatus(req, res) {
1203
1212
  try {
1204
- sendJson(res, 200, await getServerStatus());
1213
+ sendJson(res, 200, await getServerStatus({ requestHost: hostFromHeader(req.headers.host) }));
1205
1214
  } catch (err) {
1206
1215
  sendJson(res, 500, { error: String(err) });
1207
1216
  }
@@ -1229,17 +1238,20 @@ async function handleStartServer(req, res, opts) {
1229
1238
  sendJson(res, 400, { error: 'listenMode must be "local" or "network"' });
1230
1239
  return;
1231
1240
  }
1241
+ const listenMode = opts.uiMode === "server" ? "network" : body.listenMode;
1232
1242
  const request = {
1233
1243
  favoritesOnly: body.favoritesOnly,
1234
1244
  freeModelsOnly: Boolean(body.freeModelsOnly),
1235
1245
  exposedProviders: Array.isArray(body.exposedProviders) ? body.exposedProviders : null,
1236
1246
  maskGatewayIds: body.maskGatewayIds,
1237
- listenMode: body.listenMode,
1247
+ listenMode,
1238
1248
  passwordMode: body.passwordMode === "saved" ? "saved" : "new",
1239
1249
  password: typeof body.password === "string" ? body.password : void 0,
1240
1250
  savePassword: Boolean(body.savePassword)
1241
1251
  };
1242
- const result = await startGatewayServer(request);
1252
+ const result = await startGatewayServer(request, {
1253
+ requestHost: hostFromHeader(req.headers.host)
1254
+ });
1243
1255
  if (result.ok) {
1244
1256
  notifyServerLifecycle(opts, {
1245
1257
  type: "started",
@@ -1337,6 +1349,7 @@ async function handleBrowseFolder(res) {
1337
1349
  var __dirname = dirname(fileURLToPath(import.meta.url));
1338
1350
  var PUBLIC_DIR = join2(__dirname, "ui", "public");
1339
1351
  var LOCK_FILE = join2(getAppHome(), "ui.lock");
1352
+ var DEFAULT_SERVER_UI_PORT = 8787;
1340
1353
  var MIME = {
1341
1354
  ".html": "text/html; charset=utf-8",
1342
1355
  ".js": "application/javascript; charset=utf-8",
@@ -1349,14 +1362,47 @@ function ext(path) {
1349
1362
  const i = path.lastIndexOf(".");
1350
1363
  return i >= 0 ? path.slice(i) : "";
1351
1364
  }
1352
- function buildStaticCache() {
1365
+ function resolveUiMode(opts = {}, env = process.env) {
1366
+ if (opts.serverMode) return "server";
1367
+ return env.RELAY_AI_UI_MODE === "server" ? "server" : "full";
1368
+ }
1369
+ function resolveServerUiPort(opts, env) {
1370
+ if (opts.port != null) return opts.port;
1371
+ const envPort = Number(env.RELAY_AI_UI_PORT);
1372
+ return Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_SERVER_UI_PORT;
1373
+ }
1374
+ function resolveUiRuntimeConfig(opts = {}, env = process.env) {
1375
+ const mode = resolveUiMode(opts, env);
1376
+ if (mode === "server") {
1377
+ return {
1378
+ mode,
1379
+ host: "0.0.0.0",
1380
+ port: resolveServerUiPort(opts, env),
1381
+ openBrowser: false,
1382
+ confirmShutdownOnSigint: false
1383
+ };
1384
+ }
1385
+ return {
1386
+ mode,
1387
+ host: "127.0.0.1",
1388
+ port: opts.port ?? 0,
1389
+ openBrowser: true,
1390
+ confirmShutdownOnSigint: true
1391
+ };
1392
+ }
1393
+ function buildStaticCache(mode) {
1353
1394
  const cache = /* @__PURE__ */ new Map();
1354
1395
  try {
1355
1396
  for (const name of readdirSync(PUBLIC_DIR)) {
1356
1397
  const mime = MIME[ext(name)];
1357
1398
  if (!mime) continue;
1358
1399
  const raw = readFileSync(join2(PUBLIC_DIR, name));
1359
- const content = name === "index.html" ? Buffer.from(raw.toString("utf8").replace("{{VERSION}}", VERSION)) : raw;
1400
+ let content = raw;
1401
+ if (name === "index.html") {
1402
+ content = Buffer.from(
1403
+ raw.toString("utf8").replaceAll("{{VERSION}}", VERSION).replaceAll("{{UI_MODE}}", mode)
1404
+ );
1405
+ }
1360
1406
  cache.set(`/${name}`, { content, mime });
1361
1407
  }
1362
1408
  } catch {
@@ -1392,13 +1438,16 @@ function formatUiServerLifecycleMessage(event) {
1392
1438
  async function resolveUiShutdownDecision(signal, promptClose = () => p.confirm({
1393
1439
  message: "Relay-AI UI is still running. Close it?",
1394
1440
  initialValue: true
1395
- })) {
1396
- if (signal !== "SIGINT") return "close";
1441
+ }), opts) {
1442
+ const confirmOnSigint = opts?.confirmOnSigint !== false;
1443
+ if (signal !== "SIGINT" || !confirmOnSigint) return "close";
1397
1444
  const shouldClose = await promptClose();
1398
1445
  if (p.isCancel(shouldClose)) return "close";
1399
1446
  return shouldClose ? "close" : "keep";
1400
1447
  }
1401
1448
  async function runUiCommand(opts = {}) {
1449
+ const runtime = resolveUiRuntimeConfig(opts);
1450
+ await ensureOpencodeCloudProviders();
1402
1451
  const existing = checkExistingServer();
1403
1452
  if (existing) {
1404
1453
  console.log(`
@@ -1409,10 +1458,10 @@ async function runUiCommand(opts = {}) {
1409
1458
  if (opts.trace) {
1410
1459
  process.env.RELAY_AI_TRACE = "1";
1411
1460
  }
1412
- const staticCache = buildStaticCache();
1461
+ const staticCache = buildStaticCache(runtime.mode);
1413
1462
  const traceLogPath = opts.trace ? getUiDebugLogPath() : void 0;
1414
1463
  const trace = traceLogPath ? makeTraceLogger(traceLogPath) : void 0;
1415
- trace?.("ui server starting");
1464
+ trace?.(`ui server starting mode=${runtime.mode}`);
1416
1465
  const server = createServer((req, res) => {
1417
1466
  const url2 = req.url ?? "/";
1418
1467
  res.setHeader("X-Content-Type-Options", "nosniff");
@@ -1420,6 +1469,7 @@ async function runUiCommand(opts = {}) {
1420
1469
  handleUiApiRequest(req, res, {
1421
1470
  trace: opts.trace,
1422
1471
  traceLogPath,
1472
+ uiMode: runtime.mode,
1423
1473
  onServerLifecycle: (event) => {
1424
1474
  console.log(`
1425
1475
  ${formatUiServerLifecycleMessage(event)}
@@ -1440,7 +1490,7 @@ async function runUiCommand(opts = {}) {
1440
1490
  res.end("Not found");
1441
1491
  });
1442
1492
  await new Promise((resolve, reject) => {
1443
- server.listen(0, "127.0.0.1", () => resolve());
1493
+ server.listen(runtime.port, runtime.host, () => resolve());
1444
1494
  server.once("error", reject);
1445
1495
  });
1446
1496
  const addr = server.address();
@@ -1449,9 +1499,10 @@ async function runUiCommand(opts = {}) {
1449
1499
  return 1;
1450
1500
  }
1451
1501
  const port = addr.port;
1452
- const url = `http://127.0.0.1:${port}`;
1502
+ const displayHost = runtime.host === "0.0.0.0" ? "127.0.0.1" : runtime.host;
1503
+ const url = `http://${displayHost}:${port}`;
1453
1504
  mkdirSync(getAppHome(), { recursive: true });
1454
- writeFileSync2(LOCK_FILE, JSON.stringify({ pid: process.pid, port }));
1505
+ writeFileSync2(LOCK_FILE, JSON.stringify({ pid: process.pid, port, mode: runtime.mode }));
1455
1506
  const cleanup = () => {
1456
1507
  removeLock();
1457
1508
  server.close();
@@ -1461,7 +1512,11 @@ async function runUiCommand(opts = {}) {
1461
1512
  const handleSignal = async (signal) => {
1462
1513
  if (handlingSignal) return;
1463
1514
  handlingSignal = true;
1464
- const decision = await resolveUiShutdownDecision(signal);
1515
+ const decision = await resolveUiShutdownDecision(
1516
+ signal,
1517
+ void 0,
1518
+ { confirmOnSigint: runtime.confirmShutdownOnSigint }
1519
+ );
1465
1520
  if (decision === "keep") {
1466
1521
  handlingSignal = false;
1467
1522
  return;
@@ -1474,21 +1529,28 @@ async function runUiCommand(opts = {}) {
1474
1529
  process.on("SIGTERM", () => {
1475
1530
  void handleSignal("SIGTERM");
1476
1531
  });
1532
+ const modeLabel = runtime.mode === "server" ? " (server admin)" : "";
1477
1533
  console.log(`
1478
- ${pc.bold("relay-ai UI")} ${pc.cyan(url)}
1534
+ ${pc.bold("relay-ai UI")}${modeLabel} ${pc.cyan(url)}
1479
1535
  ${pc.dim("Press Ctrl+C to stop")}
1480
1536
  `);
1537
+ if (runtime.mode === "server") {
1538
+ console.log(` ${pc.dim("Gateway API (when started from Server tab): http://127.0.0.1:17645")}
1539
+ `);
1540
+ }
1481
1541
  if (traceLogPath) {
1482
1542
  console.log(` ${pc.dim(`Trace log: ${traceLogPath}`)}
1483
1543
  `);
1484
1544
  trace?.(`ui server listening ${url}`);
1485
1545
  }
1486
- try {
1487
- const { default: open } = await import("open");
1488
- await open(url);
1489
- trace?.(`browser open ${url}`);
1490
- } catch {
1491
- trace?.(`browser open failed ${url}`);
1546
+ if (runtime.openBrowser) {
1547
+ try {
1548
+ const { default: open } = await import("open");
1549
+ await open(url);
1550
+ trace?.(`browser open ${url}`);
1551
+ } catch {
1552
+ trace?.(`browser open failed ${url}`);
1553
+ }
1492
1554
  }
1493
1555
  await new Promise(() => {
1494
1556
  });
@@ -1497,7 +1559,9 @@ async function runUiCommand(opts = {}) {
1497
1559
  export {
1498
1560
  formatUiServerLifecycleMessage,
1499
1561
  isUiApiRoute,
1562
+ resolveUiMode,
1563
+ resolveUiRuntimeConfig,
1500
1564
  resolveUiShutdownDecision,
1501
1565
  runUiCommand
1502
1566
  };
1503
- //# sourceMappingURL=ui-command-GATWK5JC.js.map
1567
+ //# sourceMappingURL=ui-command-DJZIIIWC.js.map