neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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 (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -1,5 +1,63 @@
1
1
  'use strict';
2
2
 
3
+ // ── Shared UI Helpers ──────────────────────────────────────────────────────
4
+
5
+ function showToast(message, type = 'info') {
6
+ // type: 'info' | 'success' | 'error'
7
+ let stack = document.getElementById('admin-toast-stack');
8
+ if (!stack) {
9
+ stack = document.createElement('div');
10
+ stack.id = 'admin-toast-stack';
11
+ stack.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;';
12
+ document.body.appendChild(stack);
13
+ }
14
+ const toast = document.createElement('div');
15
+ const colors = { info: 'var(--info,#3b82f6)', success: 'var(--success,#22c55e)', error: 'var(--danger,#ef4444)' };
16
+ toast.style.cssText = `background:var(--bg-primary,#1a1a1a);border:1px solid ${colors[type] || colors.info};border-radius:10px;padding:12px 18px;color:var(--text,#fff);font-size:13px;box-shadow:0 6px 24px rgba(0,0,0,0.4);pointer-events:auto;max-width:360px;line-height:1.5;opacity:0;transform:translateY(8px);transition:opacity 0.18s,transform 0.18s;`;
17
+ toast.textContent = message;
18
+ stack.appendChild(toast);
19
+ requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; });
20
+ const remove = () => {
21
+ toast.style.opacity = '0';
22
+ toast.style.transform = 'translateY(8px)';
23
+ setTimeout(() => toast.remove(), 200);
24
+ };
25
+ toast.onclick = remove;
26
+ setTimeout(remove, 4000);
27
+ }
28
+
29
+ function showConfirmModal({ title, body, confirmLabel = 'Confirm', confirmClass = 'btn-danger', onConfirm }) {
30
+ return new Promise((resolve) => {
31
+ const overlay = document.createElement('div');
32
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:99990;backdrop-filter:blur(2px);';
33
+ const modal = document.createElement('div');
34
+ modal.setAttribute('role', 'dialog');
35
+ modal.setAttribute('aria-modal', 'true');
36
+ modal.setAttribute('aria-labelledby', 'admin-modal-title');
37
+ modal.style.cssText = 'width:440px;max-width:calc(100vw - 32px);background:var(--bg-primary,#1a1a1a);border:1px solid var(--border,#2a2a2a);border-radius:12px;padding:28px;box-shadow:0 16px 48px rgba(0,0,0,0.6);';
38
+ modal.innerHTML = `
39
+ <div id="admin-modal-title" style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:12px;"></div>
40
+ <div style="font-size:13px;color:var(--text-muted);line-height:1.6;margin-bottom:24px;">${body}</div>
41
+ <div style="display:flex;gap:10px;justify-content:flex-end;">
42
+ <button class="btn btn-ghost" id="admin-modal-cancel" style="padding:8px 16px;">Cancel</button>
43
+ <button class="btn ${confirmClass}" id="admin-modal-confirm" style="padding:8px 16px;">${confirmLabel}</button>
44
+ </div>
45
+ `;
46
+ modal.querySelector('#admin-modal-title').textContent = title;
47
+ overlay.appendChild(modal);
48
+ document.body.appendChild(overlay);
49
+ const cancel = () => { overlay.remove(); resolve(false); };
50
+ const confirm = () => { overlay.remove(); resolve(true); if (onConfirm) onConfirm(); };
51
+ modal.querySelector('#admin-modal-cancel').onclick = cancel;
52
+ modal.querySelector('#admin-modal-confirm').onclick = confirm;
53
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
54
+ document.addEventListener('keydown', function handler(e) {
55
+ if (e.key === 'Escape') { cancel(); document.removeEventListener('keydown', handler); }
56
+ });
57
+ modal.querySelector('#admin-modal-confirm').focus();
58
+ });
59
+ }
60
+
3
61
  // ── State ──────────────────────────────────────────────────────────────────
4
62
 
5
63
  let currentPage = 'overview';
@@ -16,29 +74,21 @@ function showPage(page, btn) {
16
74
  if (btn) btn.classList.add('active');
17
75
  currentPage = page;
18
76
 
19
- const loaders = { overview: loadHealth, logs: loadLogs, issues: loadIssues, updates: loadVersion, config: loadConfig, providers: loadProviders, models: loadModels, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
77
+ const billingLoader = typeof loadBilling !== 'undefined' ? loadBilling : null;
78
+ const loaders = { overview: loadHealth, logs: loadLogs, issues: loadIssues, updates: loadVersion, config: loadConfig, providers: loadProviders, models: loadModels, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess, billing: billingLoader, integrations: loadIntegrationsConfig };
20
79
  loaders[page]?.();
21
80
  }
22
81
 
23
82
  // ── Theme ──────────────────────────────────────────────────────────────────
24
83
 
25
- function applyTheme(theme) {
26
- const isLight = theme === 'light';
84
+ function applyTheme(isLight) {
27
85
  document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
28
- const label = document.getElementById('theme-toggle-label');
29
- if (label) label.textContent = isLight ? 'Dark mode' : 'Light mode';
30
- }
31
-
32
- function toggleTheme() {
33
- const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
34
- try { localStorage.setItem('admin-theme', next); } catch {}
35
- applyTheme(next);
36
86
  }
37
87
 
38
88
  function initTheme() {
39
- let stored = 'dark';
40
- try { stored = localStorage.getItem('admin-theme') || 'dark'; } catch {}
41
- applyTheme(stored);
89
+ const mq = window.matchMedia('(prefers-color-scheme: light)');
90
+ applyTheme(mq.matches);
91
+ mq.addEventListener('change', (e) => applyTheme(e.matches));
42
92
  }
43
93
 
44
94
  async function signOut() {
@@ -289,11 +339,11 @@ async function triggerUpdate() {
289
339
  const res = await api('/admin/api/update', { method: 'POST' });
290
340
  if (!res.ok) {
291
341
  const body = await res.json().catch(() => ({}));
292
- alert(body.error || 'Failed to start update');
342
+ showToast(body.error || 'Failed to start update', 'error');
293
343
  }
294
344
  setTimeout(loadVersion, 1200);
295
345
  } catch (err) {
296
- if (err.message !== 'unauthorized') alert('Failed to trigger update');
346
+ if (err.message !== 'unauthorized') showToast('Failed to trigger update', 'error');
297
347
  }
298
348
  }
299
349
 
@@ -303,6 +353,10 @@ async function loadConfig() {
303
353
  const el = document.getElementById('config-content');
304
354
  if (!el) return;
305
355
  loadEmailConfig();
356
+ loadGeneralConfig();
357
+ loadVmConfig();
358
+ loadScreenRecorderConfig();
359
+ loadBillingSetupConfig();
306
360
  try {
307
361
  const data = await api('/admin/api/config').then((r) => r.json());
308
362
  const cfg = data.config || {};
@@ -451,12 +505,12 @@ async function persistEmailConfig(payload, btn) {
451
505
  });
452
506
  const body = await res.json().catch(() => ({}));
453
507
  if (!res.ok) {
454
- alert(body.error || 'Failed to save email settings');
508
+ showToast(body.error || 'Failed to save email settings', 'error');
455
509
  return;
456
510
  }
457
511
  await loadEmailConfig();
458
512
  } catch (err) {
459
- if (err.message !== 'unauthorized') alert('Network error');
513
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
460
514
  } finally {
461
515
  if (btn?.isConnected) {
462
516
  btn.disabled = false;
@@ -526,14 +580,14 @@ async function saveProvider(key, btn) {
526
580
  });
527
581
  if (!res.ok) {
528
582
  const body = await res.json().catch(() => ({}));
529
- alert(body.error || 'Failed to save');
583
+ showToast(body.error || 'Failed to save', 'error');
530
584
  } else {
531
585
  btn.textContent = 'Saved!';
532
586
  setTimeout(loadProviders, 800);
533
587
  return;
534
588
  }
535
589
  } catch (err) {
536
- if (err.message !== 'unauthorized') alert('Network error');
590
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
537
591
  }
538
592
  btn.disabled = false;
539
593
  btn.textContent = original;
@@ -549,7 +603,7 @@ async function clearProvider(key, btn) {
549
603
  });
550
604
  setTimeout(loadProviders, 400);
551
605
  } catch (err) {
552
- if (err.message !== 'unauthorized') alert('Network error');
606
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
553
607
  btn.disabled = false;
554
608
  }
555
609
  }
@@ -685,19 +739,453 @@ async function saveEnabledModels(btn) {
685
739
  });
686
740
  if (!res.ok) {
687
741
  const body = await res.json().catch(() => ({}));
688
- alert(body.error || 'Failed to save');
742
+ showToast(body.error || 'Failed to save', 'error');
689
743
  } else {
690
744
  btn.textContent = 'Saved!';
691
745
  setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
692
746
  return;
693
747
  }
694
748
  } catch (err) {
695
- if (err.message !== 'unauthorized') alert('Network error');
749
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
696
750
  }
697
751
  btn.disabled = false;
698
752
  btn.textContent = original;
699
753
  }
700
754
 
755
+ // ── General Config ─────────────────────────────────────────────────────────
756
+
757
+ async function loadGeneralConfig() {
758
+ const el = document.getElementById('general-config-content');
759
+ if (!el) return;
760
+ try {
761
+ const data = await api('/admin/api/config/general').then((r) => r.json());
762
+ const s = data.settings || {};
763
+ el.innerHTML = `
764
+ <form id="general-config-form" onsubmit="saveGeneralConfig(event)">
765
+ <div class="email-settings-grid">
766
+ <div class="field field-wide">
767
+ <label for="gc-public-url">Public URL</label>
768
+ <input type="text" id="gc-public-url" value="${esc(s.publicUrl)}" autocomplete="off" spellcheck="false" placeholder="https://agent.example.com">
769
+ </div>
770
+ <div class="field">
771
+ <label for="gc-profile">Deployment profile</label>
772
+ <select id="gc-profile">
773
+ <option value="prod" ${s.neoagentProfile === 'prod' ? 'selected' : ''}>prod (multi-user / isolated VM)</option>
774
+ <option value="private" ${s.neoagentProfile === 'private' ? 'selected' : ''}>private (single-user)</option>
775
+ </select>
776
+ </div>
777
+ <div class="field">
778
+ <label for="gc-allowed-origins">Allowed CORS origins</label>
779
+ <input type="text" id="gc-allowed-origins" value="${esc(s.allowedOrigins)}" autocomplete="off" spellcheck="false" placeholder="https://a.com,https://b.com">
780
+ </div>
781
+ <div class="field">
782
+ <label for="gc-memory-interval">Memory ingestion interval (ms)</label>
783
+ <input type="number" min="1000" step="1000" id="gc-memory-interval" value="${esc(s.memoryIngestionIntervalMs)}" autocomplete="off">
784
+ </div>
785
+ </div>
786
+ <div class="email-settings-checks" style="margin-top:4px;">
787
+ ${emailConfigCheckbox('gc-secure-cookies', 'Secure cookies (required behind HTTPS / TLS proxy)', s.secureCookies)}
788
+ ${emailConfigCheckbox('gc-meshtastic', 'Meshtastic enabled', s.meshtasticEnabled)}
789
+ </div>
790
+ <button class="btn btn-primary" type="submit">Save General Settings</button>
791
+ </form>`;
792
+ } catch (err) {
793
+ if (err.message !== 'unauthorized') {
794
+ el.innerHTML = '<div class="empty">Failed to load general configuration</div>';
795
+ }
796
+ }
797
+ }
798
+
799
+ async function saveGeneralConfig(event) {
800
+ event.preventDefault();
801
+ const btn = event.submitter;
802
+ const original = btn?.textContent;
803
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
804
+ try {
805
+ const res = await api('/admin/api/config/general', {
806
+ method: 'PUT',
807
+ headers: { 'Content-Type': 'application/json' },
808
+ body: JSON.stringify({
809
+ publicUrl: document.getElementById('gc-public-url')?.value?.trim() || '',
810
+ secureCookies: document.getElementById('gc-secure-cookies')?.checked === true,
811
+ neoagentProfile: document.getElementById('gc-profile')?.value || '',
812
+ allowedOrigins: document.getElementById('gc-allowed-origins')?.value?.trim() || '',
813
+ meshtasticEnabled: document.getElementById('gc-meshtastic')?.checked === true,
814
+ memoryIngestionIntervalMs: parseInt(document.getElementById('gc-memory-interval')?.value || '600000', 10),
815
+ }),
816
+ });
817
+ const body = await res.json().catch(() => ({}));
818
+ if (!res.ok) {
819
+ showToast(body.error || 'Failed to save', 'error');
820
+ } else {
821
+ showToast('General settings saved', 'success');
822
+ }
823
+ } catch (err) {
824
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
825
+ } finally {
826
+ if (btn?.isConnected) { btn.disabled = false; btn.textContent = original; }
827
+ }
828
+ }
829
+
830
+ // ── VM Runtime Config ──────────────────────────────────────────────────────
831
+
832
+ async function loadVmConfig() {
833
+ const el = document.getElementById('vm-config-content');
834
+ if (!el) return;
835
+ try {
836
+ const data = await api('/admin/api/config/vm').then((r) => r.json());
837
+ const s = data.settings || {};
838
+ el.innerHTML = `
839
+ <form id="vm-config-form" onsubmit="saveVmConfig(event)">
840
+ <div class="email-settings-grid">
841
+ <div class="field field-wide">
842
+ <label for="vm-base-image-url">Base image URL</label>
843
+ <input type="text" id="vm-base-image-url" value="${esc(s.vmBaseImageUrl)}" autocomplete="off" spellcheck="false"
844
+ placeholder="https://cloud-images.ubuntu.com/…">
845
+ </div>
846
+ <div class="field field-wide">
847
+ <label for="vm-base-image">Local base image path override</label>
848
+ <input type="text" id="vm-base-image" value="${esc(s.vmBaseImage)}" autocomplete="off" spellcheck="false"
849
+ placeholder="/path/to/base.img (takes precedence over URL)">
850
+ </div>
851
+ <div class="field">
852
+ <label for="vm-memory-mb">Memory (MB)</label>
853
+ <input type="number" min="512" step="256" id="vm-memory-mb" value="${esc(s.vmMemoryMb)}" autocomplete="off">
854
+ </div>
855
+ <div class="field">
856
+ <label for="vm-cpus">vCPUs</label>
857
+ <input type="number" min="1" step="1" id="vm-cpus" value="${esc(s.vmCpus)}" autocomplete="off">
858
+ </div>
859
+ </div>
860
+ <button class="btn btn-primary" type="submit" style="margin-top:8px;">Save VM Settings</button>
861
+ </form>`;
862
+ } catch (err) {
863
+ if (err.message !== 'unauthorized') {
864
+ el.innerHTML = '<div class="empty">Failed to load VM configuration</div>';
865
+ }
866
+ }
867
+ }
868
+
869
+ async function saveVmConfig(event) {
870
+ event.preventDefault();
871
+ const btn = event.submitter;
872
+ const original = btn?.textContent;
873
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
874
+ try {
875
+ const res = await api('/admin/api/config/vm', {
876
+ method: 'PUT',
877
+ headers: { 'Content-Type': 'application/json' },
878
+ body: JSON.stringify({
879
+ vmBaseImageUrl: document.getElementById('vm-base-image-url')?.value?.trim() || '',
880
+ vmBaseImage: document.getElementById('vm-base-image')?.value?.trim() || '',
881
+ vmMemoryMb: parseInt(document.getElementById('vm-memory-mb')?.value || '4096', 10),
882
+ vmCpus: parseInt(document.getElementById('vm-cpus')?.value || '2', 10),
883
+ }),
884
+ });
885
+ const body = await res.json().catch(() => ({}));
886
+ if (!res.ok) {
887
+ showToast(body.error || 'Failed to save', 'error');
888
+ } else {
889
+ showToast('VM settings saved', 'success');
890
+ }
891
+ } catch (err) {
892
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
893
+ } finally {
894
+ if (btn?.isConnected) { btn.disabled = false; btn.textContent = original; }
895
+ }
896
+ }
897
+
898
+ // ── Screen Recorder Config ─────────────────────────────────────────────────
899
+
900
+ async function loadScreenRecorderConfig() {
901
+ const el = document.getElementById('screen-recorder-config-content');
902
+ if (!el) return;
903
+ try {
904
+ const data = await api('/admin/api/config/screen-recorder').then((r) => r.json());
905
+ const s = data.settings || {};
906
+ el.innerHTML = `
907
+ <form id="screen-recorder-config-form" onsubmit="saveScreenRecorderConfig(event)">
908
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:16px;line-height:1.5;">
909
+ Captures the backend host's macOS desktop. Enable only when the host is the configured user's desktop.
910
+ </div>
911
+ <div class="email-settings-grid">
912
+ <div class="field">
913
+ <label for="sr-user-id">User ID (owner of screen history)</label>
914
+ <input type="text" id="sr-user-id" value="${esc(s.userId)}" autocomplete="off" spellcheck="false"
915
+ placeholder="Required when enabled">
916
+ </div>
917
+ <div class="field">
918
+ <label for="sr-interval-ms">Capture interval (ms)</label>
919
+ <input type="number" min="1000" step="1000" id="sr-interval-ms" value="${esc(s.intervalMs)}" autocomplete="off">
920
+ </div>
921
+ <div class="field">
922
+ <label for="sr-retention-days">Retention (days)</label>
923
+ <input type="number" min="1" step="1" id="sr-retention-days" value="${esc(s.retentionDays)}" autocomplete="off">
924
+ </div>
925
+ </div>
926
+ <div class="email-settings-checks" style="margin-top:4px;">
927
+ ${emailConfigCheckbox('sr-enabled', 'Screen recorder enabled', s.enabled)}
928
+ </div>
929
+ <button class="btn btn-primary" type="submit">Save Screen Recorder Settings</button>
930
+ </form>`;
931
+ } catch (err) {
932
+ if (err.message !== 'unauthorized') {
933
+ el.innerHTML = '<div class="empty">Failed to load screen recorder configuration</div>';
934
+ }
935
+ }
936
+ }
937
+
938
+ async function saveScreenRecorderConfig(event) {
939
+ event.preventDefault();
940
+ const btn = event.submitter;
941
+ const original = btn?.textContent;
942
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
943
+ try {
944
+ const res = await api('/admin/api/config/screen-recorder', {
945
+ method: 'PUT',
946
+ headers: { 'Content-Type': 'application/json' },
947
+ body: JSON.stringify({
948
+ enabled: document.getElementById('sr-enabled')?.checked === true,
949
+ userId: document.getElementById('sr-user-id')?.value?.trim() || '',
950
+ intervalMs: parseInt(document.getElementById('sr-interval-ms')?.value || '10000', 10),
951
+ retentionDays: parseInt(document.getElementById('sr-retention-days')?.value || '7', 10),
952
+ }),
953
+ });
954
+ const body = await res.json().catch(() => ({}));
955
+ if (!res.ok) {
956
+ showToast(body.error || 'Failed to save', 'error');
957
+ } else {
958
+ showToast('Screen recorder settings saved', 'success');
959
+ }
960
+ } catch (err) {
961
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
962
+ } finally {
963
+ if (btn?.isConnected) { btn.disabled = false; btn.textContent = original; }
964
+ }
965
+ }
966
+
967
+ // ── Integrations Config ────────────────────────────────────────────────────
968
+
969
+ const INTEGRATION_FIELD_LABELS = {
970
+ clientId: 'Client ID',
971
+ clientSecret: 'Client secret',
972
+ redirectUri: 'Redirect URI',
973
+ tenantId: 'Tenant ID',
974
+ apiKey: 'API key',
975
+ };
976
+
977
+ async function loadIntegrationsConfig() {
978
+ const el = document.getElementById('integrations-config-content');
979
+ const deepEl = document.getElementById('deepgram-config-content');
980
+ if (!el) return;
981
+ try {
982
+ const data = await api('/admin/api/config/integrations').then((r) => r.json());
983
+ const integrations = data.integrations || [];
984
+
985
+ el.innerHTML = integrations.map((integration) => {
986
+ const fields = integration.fields.map((f) => {
987
+ const fieldId = `int-${integration.key}-${f.name}`;
988
+ const label = INTEGRATION_FIELD_LABELS[f.name] || f.name;
989
+ if (f.secret) {
990
+ return `<div class="field">
991
+ <label for="${fieldId}">${esc(label)}</label>
992
+ <input type="password" id="${fieldId}" value="" autocomplete="new-password"
993
+ placeholder="${f.configured ? 'Stored — paste new value to update' : 'Not set'}">
994
+ ${f.configured ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">A value is stored. Leave blank to keep it.</div>` : ''}
995
+ </div>`;
996
+ }
997
+ return `<div class="field">
998
+ <label for="${fieldId}">${esc(label)}</label>
999
+ <input type="text" id="${fieldId}" value="${esc(f.value || '')}" autocomplete="off" spellcheck="false">
1000
+ </div>`;
1001
+ }).join('');
1002
+
1003
+ const badge = integration.configured
1004
+ ? '<span class="badge badge-ok">configured</span>'
1005
+ : '<span class="badge badge-idle">not set</span>';
1006
+
1007
+ return `<div class="integration-section" data-key="${esc(integration.key)}">
1008
+ <div class="integration-header">
1009
+ <span class="integration-name">${esc(integration.label)}</span>
1010
+ ${badge}
1011
+ </div>
1012
+ <div class="email-settings-grid">${fields}</div>
1013
+ </div>`;
1014
+ }).join('');
1015
+
1016
+ if (deepEl) {
1017
+ const dg = data.deepgram || {};
1018
+ deepEl.innerHTML = `
1019
+ <form id="deepgram-config-form" onsubmit="saveDeepgramConfig(event)">
1020
+ <div class="email-settings-grid">
1021
+ <div class="field">
1022
+ <label for="dg-base-url">Base URL</label>
1023
+ <input type="text" id="dg-base-url" value="${esc(dg.baseUrl)}" autocomplete="off" spellcheck="false"
1024
+ placeholder="https://api.deepgram.com">
1025
+ </div>
1026
+ <div class="field">
1027
+ <label for="dg-model">Model</label>
1028
+ <input type="text" id="dg-model" value="${esc(dg.model)}" autocomplete="off" spellcheck="false"
1029
+ placeholder="nova-3">
1030
+ </div>
1031
+ <div class="field">
1032
+ <label for="dg-language">Language</label>
1033
+ <input type="text" id="dg-language" value="${esc(dg.language)}" autocomplete="off" spellcheck="false"
1034
+ placeholder="multi">
1035
+ </div>
1036
+ </div>
1037
+ <button class="btn btn-primary" type="submit" style="margin-top:8px;">Save Deepgram Settings</button>
1038
+ </form>`;
1039
+ }
1040
+ } catch (err) {
1041
+ if (err.message !== 'unauthorized') {
1042
+ el.innerHTML = '<div class="empty">Failed to load integrations configuration</div>';
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ async function saveIntegrationsConfig(btn) {
1048
+ if (btn) { btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Saving…';
1049
+ try {
1050
+ const integrationSections = document.querySelectorAll('.integration-section');
1051
+ const integrations = {};
1052
+ for (const section of integrationSections) {
1053
+ const key = section.dataset.key;
1054
+ integrations[key] = {};
1055
+ for (const input of section.querySelectorAll('input')) {
1056
+ const fieldName = input.id.replace(`int-${key}-`, '');
1057
+ integrations[key][fieldName] = input.value;
1058
+ }
1059
+ }
1060
+ const res = await api('/admin/api/config/integrations', {
1061
+ method: 'PUT',
1062
+ headers: { 'Content-Type': 'application/json' },
1063
+ body: JSON.stringify({ integrations }),
1064
+ });
1065
+ const body = await res.json().catch(() => ({}));
1066
+ if (!res.ok) {
1067
+ showToast(body.error || 'Failed to save', 'error');
1068
+ } else {
1069
+ showToast('Integration settings saved', 'success');
1070
+ setTimeout(loadIntegrationsConfig, 600);
1071
+ }
1072
+ } catch (err) {
1073
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
1074
+ } finally {
1075
+ if (btn.isConnected) { btn.disabled = false; btn.textContent = orig; }
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ async function saveDeepgramConfig(event) {
1081
+ event.preventDefault();
1082
+ const btn = event.submitter;
1083
+ const original = btn?.textContent;
1084
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
1085
+ try {
1086
+ const res = await api('/admin/api/config/integrations', {
1087
+ method: 'PUT',
1088
+ headers: { 'Content-Type': 'application/json' },
1089
+ body: JSON.stringify({
1090
+ deepgram: {
1091
+ baseUrl: document.getElementById('dg-base-url')?.value?.trim() || '',
1092
+ model: document.getElementById('dg-model')?.value?.trim() || '',
1093
+ language: document.getElementById('dg-language')?.value?.trim() || '',
1094
+ },
1095
+ }),
1096
+ });
1097
+ const body = await res.json().catch(() => ({}));
1098
+ if (!res.ok) {
1099
+ showToast(body.error || 'Failed to save', 'error');
1100
+ } else {
1101
+ showToast('Deepgram settings saved', 'success');
1102
+ }
1103
+ } catch (err) {
1104
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
1105
+ } finally {
1106
+ if (btn?.isConnected) { btn.disabled = false; btn.textContent = original; }
1107
+ }
1108
+ }
1109
+
1110
+ // ── Billing Setup Config ───────────────────────────────────────────────────
1111
+
1112
+ async function loadBillingSetupConfig() {
1113
+ const el = document.getElementById('billing-setup-content');
1114
+ if (!el) return;
1115
+ try {
1116
+ const data = await api('/admin/api/config/billing-setup').then((r) => r.json());
1117
+ const s = data.settings || {};
1118
+ el.innerHTML = `
1119
+ <form id="billing-setup-form" onsubmit="saveBillingSetupConfig(event)">
1120
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:16px;line-height:1.5;">
1121
+ Enable billing to expose Stripe-backed subscription flows. Requires a server restart when toggling.
1122
+ </div>
1123
+ <div class="email-settings-grid">
1124
+ <div class="field">
1125
+ <label for="bs-publishable-key">Stripe publishable key</label>
1126
+ <input type="text" id="bs-publishable-key" value="${esc(s.stripePublishableKey)}" autocomplete="off" spellcheck="false"
1127
+ placeholder="pk_test_…">
1128
+ </div>
1129
+ <div class="field">
1130
+ <label for="bs-secret-key">Stripe secret key</label>
1131
+ <input type="password" id="bs-secret-key" value="" autocomplete="new-password"
1132
+ placeholder="${s.stripeSecretKeyConfigured ? s.stripeSecretKeyHint + ' — paste new to update' : 'sk_test_…'}">
1133
+ ${s.stripeSecretKeyConfigured ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Key stored. Leave blank to keep it.</div>` : ''}
1134
+ </div>
1135
+ <div class="field">
1136
+ <label for="bs-webhook-secret">Stripe webhook signing secret</label>
1137
+ <input type="password" id="bs-webhook-secret" value="" autocomplete="new-password"
1138
+ placeholder="${s.stripeWebhookSecretConfigured ? 'Stored — paste new to update' : 'whsec_…'}">
1139
+ ${s.stripeWebhookSecretConfigured ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">Secret stored. Leave blank to keep it.</div>` : ''}
1140
+ </div>
1141
+ <div class="field">
1142
+ <label for="bs-trial-days">Free trial period (days)</label>
1143
+ <input type="number" min="0" step="1" id="bs-trial-days" value="${esc(s.trialDays)}" autocomplete="off">
1144
+ </div>
1145
+ </div>
1146
+ <div class="email-settings-checks" style="margin-top:4px;">
1147
+ ${emailConfigCheckbox('bs-billing-enabled', 'Billing enabled', s.billingEnabled)}
1148
+ </div>
1149
+ <button class="btn btn-primary" type="submit">Save Billing Configuration</button>
1150
+ </form>`;
1151
+ } catch (err) {
1152
+ if (err.message !== 'unauthorized') {
1153
+ el.innerHTML = '<div class="empty">Failed to load billing configuration</div>';
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ async function saveBillingSetupConfig(event) {
1159
+ event.preventDefault();
1160
+ const btn = event.submitter;
1161
+ const original = btn?.textContent;
1162
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
1163
+ try {
1164
+ const res = await api('/admin/api/config/billing-setup', {
1165
+ method: 'PUT',
1166
+ headers: { 'Content-Type': 'application/json' },
1167
+ body: JSON.stringify({
1168
+ billingEnabled: document.getElementById('bs-billing-enabled')?.checked === true,
1169
+ stripePublishableKey: document.getElementById('bs-publishable-key')?.value?.trim() || '',
1170
+ stripeSecretKey: document.getElementById('bs-secret-key')?.value || '',
1171
+ stripeWebhookSecret: document.getElementById('bs-webhook-secret')?.value || '',
1172
+ trialDays: parseInt(document.getElementById('bs-trial-days')?.value || '14', 10),
1173
+ }),
1174
+ });
1175
+ const body = await res.json().catch(() => ({}));
1176
+ if (!res.ok) {
1177
+ showToast(body.error || 'Failed to save', 'error');
1178
+ } else {
1179
+ showToast('Billing configuration saved — restart the server to apply changes', 'success');
1180
+ setTimeout(loadBillingSetupConfig, 600);
1181
+ }
1182
+ } catch (err) {
1183
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
1184
+ } finally {
1185
+ if (btn?.isConnected) { btn.disabled = false; btn.textContent = original; }
1186
+ }
1187
+ }
1188
+
701
1189
  // ── Auto-refresh ───────────────────────────────────────────────────────────
702
1190
 
703
1191
  function startPolling() {