neoagent 3.0.1-beta.8 → 3.1.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.
Files changed (100) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -12,7 +12,6 @@ async function loadBilling() {
12
12
  document.getElementById('billing-plans-content').innerHTML =
13
13
  '<div class="empty">Billing is not enabled on this server. Set <code>NEOAGENT_BILLING_ENABLED=1</code> to enable it.</div>';
14
14
  document.getElementById('billing-subs-content').innerHTML = '';
15
- document.getElementById('nav-billing').style.display = '';
16
15
  return;
17
16
  }
18
17
  const data = await r.json();
@@ -38,18 +37,18 @@ function planRowHtml(plan) {
38
37
  const id = escAttr(plan.id);
39
38
  const intervalLabel = plan.interval ? `/ ${plan.interval}` : '';
40
39
  const price = plan.price_cents === 0 ? 'Free' : `${(plan.price_cents / 100).toFixed(2)} ${(plan.currency || 'usd').toUpperCase()} ${intervalLabel}`;
41
- const status = plan.is_active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--muted)">Inactive</span>';
40
+ const status = plan.is_active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--text-muted)">Inactive</span>';
42
41
  return `
43
42
  <tr data-plan-id="${id}">
44
- <td><strong>${esc(plan.name)}</strong><br><small style="color:var(--muted)">${esc(plan.id)}</small></td>
43
+ <td><strong>${esc(plan.name)}</strong><br><small style="color:var(--text-muted)">${esc(plan.id)}</small></td>
45
44
  <td>${esc(price)}</td>
46
- <td>${plan.token_limit_4h != null ? fmtTokens(plan.token_limit_4h) : '<span style="color:var(--muted)">Default</span>'}</td>
47
- <td>${plan.token_limit_weekly != null ? fmtTokens(plan.token_limit_weekly) : '<span style="color:var(--muted)">Default</span>'}</td>
45
+ <td>${plan.token_limit_4h != null ? fmtTokens(plan.token_limit_4h) : '<span style="color:var(--text-muted)">Default</span>'}</td>
46
+ <td>${plan.token_limit_weekly != null ? fmtTokens(plan.token_limit_weekly) : '<span style="color:var(--text-muted)">Default</span>'}</td>
48
47
  <td><code style="font-size:11px">${esc(plan.stripe_price_id || '—')}</code></td>
49
48
  <td>${status}</td>
50
49
  <td>
51
- <button class="btn btn-sm" onclick="billingEditPlan('${id}')">Edit</button>
52
- <button class="btn btn-sm btn-danger" onclick="billingDeletePlan('${id}')">Delete</button>
50
+ <button class="btn btn-sm" data-action="edit" data-plan-id="${id}">Edit</button>
51
+ <button class="btn btn-sm btn-danger" data-action="delete" data-plan-id="${id}">Delete</button>
53
52
  </td>
54
53
  </tr>`;
55
54
  }
@@ -67,6 +66,13 @@ function renderPlansTable(plans) {
67
66
  </tr></thead>
68
67
  <tbody>${plans.map(planRowHtml).join('')}</tbody>
69
68
  </table>`;
69
+ el.querySelector('tbody').addEventListener('click', (e) => {
70
+ const btn = e.target.closest('[data-action]');
71
+ if (!btn) return;
72
+ const planId = btn.dataset.planId;
73
+ if (btn.dataset.action === 'edit') billingEditPlan(planId);
74
+ else if (btn.dataset.action === 'delete') billingDeletePlan(planId);
75
+ });
70
76
  }
71
77
 
72
78
  function billingShowNewPlanForm() {
@@ -78,32 +84,88 @@ function billingEditPlan(planId) {
78
84
  billingOpenPlanModal(plan);
79
85
  }
80
86
 
81
- function billingOpenPlanModal(plan) {
87
+ async function billingOpenPlanModal(plan) {
82
88
  const isNew = !plan;
83
89
  const title = isNew ? 'New Plan' : `Edit Plan: ${plan.name}`;
90
+ const allowedSet = new Set(plan?.allowed_models || []);
91
+
92
+ // Fetch available models to render the picker
93
+ let modelPickerHtml = '<div style="color:var(--text-muted);font-size:12px;padding:8px 0;">Loading models…</div>';
94
+ let fetchedModels = [];
95
+ try {
96
+ const r = await fetch('/admin/api/models');
97
+ if (r.ok) {
98
+ const data = await r.json();
99
+ fetchedModels = (data.models || []).sort((a, b) => {
100
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
101
+ return (a.label || a.id).localeCompare(b.label || b.id);
102
+ });
103
+ }
104
+ } catch {}
105
+
106
+ if (fetchedModels.length) {
107
+ const rows = fetchedModels.map((m) => {
108
+ const checked = allowedSet.has(m.id) ? 'checked' : '';
109
+ return `
110
+ <tr class="bp-model-row" style="opacity:${checked ? '1' : '0.55'}" onclick="billingModelRowClick(this)">
111
+ <td style="width:36px;text-align:center;pointer-events:none;">
112
+ <input type="checkbox" class="bp-model-cb" value="${escAttr(m.id)}" ${checked}
113
+ onchange="this.closest('tr').style.opacity=this.checked?'1':'0.55'" onclick="event.stopPropagation()">
114
+ </td>
115
+ <td style="pointer-events:none;">
116
+ <div style="font-weight:600;color:var(--text);font-size:13px;">${esc(m.label || m.id)}</div>
117
+ <div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);margin-top:1px;">${esc(m.id)}</div>
118
+ </td>
119
+ <td style="font-size:12px;text-transform:capitalize;color:var(--text-secondary);pointer-events:none;">${esc(m.provider)}</td>
120
+ </tr>`;
121
+ }).join('');
122
+
123
+ modelPickerHtml = `
124
+ <div style="border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden;max-height:200px;overflow-y:auto;">
125
+ <table class="data-table" style="margin:0;">
126
+ <thead><tr>
127
+ <th style="width:36px;position:sticky;top:0;background:var(--bg-secondary);z-index:1;"></th>
128
+ <th style="position:sticky;top:0;background:var(--bg-secondary);z-index:1;">Model</th>
129
+ <th style="position:sticky;top:0;background:var(--bg-secondary);z-index:1;">Provider</th>
130
+ </tr></thead>
131
+ <tbody>${rows}</tbody>
132
+ </table>
133
+ </div>
134
+ <div style="font-size:11px;color:var(--text-muted);margin-top:5px;">Check models to restrict this plan. Leave all unchecked to allow every model.</div>`;
135
+ } else {
136
+ modelPickerHtml = `
137
+ <input class="form-input" id="bp-models-fallback" value="${escAttr((plan?.allowed_models || []).join(', '))}" placeholder="claude-opus-4-8, gpt-4o (comma-separated, or blank for all)">
138
+ <div style="font-size:11px;color:var(--text-muted);margin-top:5px;">Configure providers first to use the model picker.</div>`;
139
+ }
140
+
84
141
  const html = `
85
- <div id="billing-plan-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000">
86
- <div style="background:var(--card-bg);border-radius:12px;padding:28px;width:540px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
87
- <h2 style="margin:0 0 20px">${esc(title)}</h2>
142
+ <div id="billing-plan-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000" onclick="if(event.target===this)billingClosePlanModal()">
143
+ <div style="background:var(--bg-card);border-radius:12px;padding:28px;width:560px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
144
+ <h2 style="margin:0 0 20px;font-size:16px;font-weight:700;color:var(--text);">${esc(title)}</h2>
88
145
  <form id="billing-plan-form" onsubmit="billingPlanFormSubmit(event)">
89
- <input type="hidden" id="bp-id" value="${esc(plan?.id || '')}">
146
+ <input type="hidden" id="bp-id" value="${escAttr(plan?.id || '')}">
147
+
90
148
  <label class="form-label">Plan ID <small>(set once, used as identifier)</small></label>
91
- <input class="form-input" id="bp-slug" value="${esc(plan?.id || '')}" ${isNew ? '' : 'disabled'} placeholder="plan_pro" style="margin-bottom:12px">
149
+ <input class="form-input" id="bp-slug" value="${escAttr(plan?.id || '')}" ${isNew ? '' : 'disabled'} placeholder="plan_pro" style="margin-bottom:14px">
150
+
92
151
  <label class="form-label">Name</label>
93
- <input class="form-input" id="bp-name" value="${esc(plan?.name || '')}" required placeholder="Pro" style="margin-bottom:12px">
152
+ <input class="form-input" id="bp-name" value="${escAttr(plan?.name || '')}" required placeholder="Pro" style="margin-bottom:14px">
153
+
94
154
  <label class="form-label">Description</label>
95
- <input class="form-input" id="bp-desc" value="${esc(plan?.description || '')}" placeholder="Optional description" style="margin-bottom:12px">
96
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
155
+ <input class="form-input" id="bp-desc" value="${escAttr(plan?.description || '')}" placeholder="Optional description" style="margin-bottom:14px">
156
+
157
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
97
158
  <div>
98
159
  <label class="form-label">Price (cents)</label>
99
160
  <input class="form-input" id="bp-price" type="number" min="0" value="${plan?.price_cents ?? 0}" required>
100
161
  </div>
101
162
  <div>
102
163
  <label class="form-label">Currency</label>
103
- <input class="form-input" id="bp-currency" value="${esc(plan?.currency || 'usd')}" placeholder="usd">
164
+ <input class="form-input" id="bp-currency" value="${escAttr(plan?.currency || 'usd')}" placeholder="usd">
104
165
  </div>
105
166
  </div>
106
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
167
+
168
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
107
169
  <div>
108
170
  <label class="form-label">Billing interval</label>
109
171
  <select class="form-input" id="bp-interval">
@@ -117,9 +179,11 @@ function billingOpenPlanModal(plan) {
117
179
  <input class="form-input" id="bp-sort" type="number" value="${plan?.sort_order ?? 0}">
118
180
  </div>
119
181
  </div>
182
+
120
183
  <label class="form-label">Stripe Price ID</label>
121
- <input class="form-input" id="bp-stripe-price" value="${esc(plan?.stripe_price_id || '')}" placeholder="price_..." style="margin-bottom:12px">
122
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
184
+ <input class="form-input" id="bp-stripe-price" value="${escAttr(plan?.stripe_price_id || '')}" placeholder="price_..." style="margin-bottom:14px">
185
+
186
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
123
187
  <div>
124
188
  <label class="form-label">4h token limit <small>(blank = default)</small></label>
125
189
  <input class="form-input" id="bp-tok-4h" type="number" min="0" value="${plan?.token_limit_4h ?? ''}" placeholder="2500000">
@@ -129,16 +193,20 @@ function billingOpenPlanModal(plan) {
129
193
  <input class="form-input" id="bp-tok-weekly" type="number" min="0" value="${plan?.token_limit_weekly ?? ''}" placeholder="10000000">
130
194
  </div>
131
195
  </div>
132
- <label class="form-label">Allowed model IDs <small>(comma-separated; blank = all)</small></label>
133
- <input class="form-input" id="bp-models" value="${esc((plan?.allowed_models || []).join(', '))}" placeholder="claude-opus-4-8, gpt-4o" style="margin-bottom:12px">
196
+
197
+ <label class="form-label" style="margin-bottom:8px;">Allowed models <small>(unchecked = not allowed on this plan)</small></label>
198
+ <div id="bp-models-wrap" style="margin-bottom:14px">${modelPickerHtml}</div>
199
+
134
200
  <label class="form-label">Features <small>(comma-separated, shown on pricing page)</small></label>
135
- <input class="form-input" id="bp-features" value="${esc((plan?.features || []).join(', '))}" placeholder="Unlimited agents, Priority support" style="margin-bottom:16px">
136
- <label style="display:flex;align-items:center;gap:8px;margin-bottom:20px;cursor:pointer">
201
+ <input class="form-input" id="bp-features" value="${escAttr((plan?.features || []).join(', '))}" placeholder="Unlimited agents, Priority support" style="margin-bottom:14px">
202
+
203
+ <label style="display:flex;align-items:center;gap:8px;margin-bottom:20px;cursor:pointer;font-size:13px;color:var(--text-secondary);text-transform:none;letter-spacing:0;font-weight:500;">
137
204
  <input type="checkbox" id="bp-active" ${!plan || plan.is_active ? 'checked' : ''}> Active
138
205
  </label>
206
+
139
207
  <div style="display:flex;gap:8px;justify-content:flex-end">
140
- <button type="button" class="btn" onclick="billingClosePlanModal()">Cancel</button>
141
- <button type="submit" class="btn btn-primary">${isNew ? 'Create' : 'Save'}</button>
208
+ <button type="button" class="btn btn-ghost" onclick="billingClosePlanModal()">Cancel</button>
209
+ <button type="submit" class="btn btn-primary">${isNew ? 'Create plan' : 'Save changes'}</button>
142
210
  </div>
143
211
  </form>
144
212
  </div>
@@ -146,6 +214,12 @@ function billingOpenPlanModal(plan) {
146
214
  document.body.insertAdjacentHTML('beforeend', html);
147
215
  }
148
216
 
217
+ function billingModelRowClick(row) {
218
+ const cb = row.querySelector('.bp-model-cb');
219
+ cb.checked = !cb.checked;
220
+ row.style.opacity = cb.checked ? '1' : '0.55';
221
+ }
222
+
149
223
  function billingClosePlanModal() {
150
224
  document.getElementById('billing-plan-modal')?.remove();
151
225
  }
@@ -160,7 +234,12 @@ async function billingPlanFormSubmit(e) {
160
234
  const n = parseInt(val, 10);
161
235
  return isNaN(n) || val === '' ? null : n;
162
236
  };
163
- const parseModels = (val) => val.split(',').map((s) => s.trim()).filter(Boolean);
237
+ const parseModels = () => {
238
+ const cbs = document.querySelectorAll('.bp-model-cb');
239
+ if (cbs.length) return Array.from(cbs).filter(cb => cb.checked).map(cb => cb.value);
240
+ const fallback = document.getElementById('bp-models-fallback');
241
+ return fallback ? fallback.value.split(',').map((s) => s.trim()).filter(Boolean) : [];
242
+ };
164
243
  const parseFeatures = (val) => val.split(',').map((s) => s.trim()).filter(Boolean);
165
244
 
166
245
  const body = {
@@ -173,7 +252,7 @@ async function billingPlanFormSubmit(e) {
173
252
  stripe_price_id: document.getElementById('bp-stripe-price').value.trim() || null,
174
253
  token_limit_4h: parseTokenLimit(document.getElementById('bp-tok-4h').value),
175
254
  token_limit_weekly: parseTokenLimit(document.getElementById('bp-tok-weekly').value),
176
- allowed_models: parseModels(document.getElementById('bp-models').value),
255
+ allowed_models: parseModels(),
177
256
  features: parseFeatures(document.getElementById('bp-features').value),
178
257
  sort_order: parseInt(document.getElementById('bp-sort').value, 10) || 0,
179
258
  is_active: document.getElementById('bp-active').checked,
@@ -184,22 +263,27 @@ async function billingPlanFormSubmit(e) {
184
263
  const method = isNew ? 'POST' : 'PUT';
185
264
  const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
186
265
  const data = await r.json();
187
- if (!r.ok) { alert(data.error || 'Failed to save plan.'); return; }
266
+ if (!r.ok) { showToast(data.error || 'Failed to save plan.', 'error'); return; }
188
267
  billingClosePlanModal();
189
268
  await loadBilling();
190
269
  } catch (err) {
191
- alert('Error: ' + err.message);
270
+ showToast('Error: ' + err.message, 'error');
192
271
  }
193
272
  }
194
273
 
195
274
  async function billingDeletePlan(planId) {
196
- if (!confirm(`Delete plan "${planId}"? It will be deactivated (not hard-deleted).`)) return;
275
+ if (!await showConfirmModal({
276
+ title: `Delete plan "${esc(planId)}"?`,
277
+ body: 'The plan will be deactivated (not hard-deleted). Existing subscribers will keep access until their period ends.',
278
+ confirmLabel: 'Delete Plan',
279
+ confirmClass: 'btn-danger',
280
+ })) return;
197
281
  try {
198
282
  const r = await fetch(`/admin/api/billing/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
199
- if (!r.ok) { const d = await r.json(); alert(d.error || 'Failed.'); return; }
283
+ if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed.', 'error'); return; }
200
284
  await loadBilling();
201
285
  } catch (err) {
202
- alert('Error: ' + err.message);
286
+ showToast('Error: ' + err.message, 'error');
203
287
  }
204
288
  }
205
289
 
@@ -234,15 +318,15 @@ function renderSubsTable(rows, total) {
234
318
  const hasPrev = _billingSubsOffset > 0;
235
319
  const hasNext = _billingSubsOffset + BILLING_SUBS_LIMIT < total;
236
320
  pag.innerHTML = `
237
- <span style="color:var(--muted);font-size:13px">${total} total</span>
321
+ <span style="color:var(--text-muted);font-size:13px">${total} total</span>
238
322
  ${hasPrev ? `<button class="btn btn-sm" onclick="_billingSubsOffset-=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">← Prev</button>` : ''}
239
323
  ${hasNext ? `<button class="btn btn-sm" onclick="_billingSubsOffset+=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">Next →</button>` : ''}`;
240
324
  }
241
325
 
242
326
  function subRowHtml(sub) {
243
327
  const userLabel = esc(sub.display_name || sub.username || String(sub.user_id));
244
- const email = sub.email ? `<br><small style="color:var(--muted)">${esc(sub.email)}</small>` : '';
245
- const statusColor = { active: 'var(--success)', trialing: 'var(--warning, #f59e0b)', past_due: 'var(--error)', canceled: 'var(--muted)' }[sub.status] || 'var(--muted)';
328
+ const email = sub.email ? `<br><small style="color:var(--text-muted)">${esc(sub.email)}</small>` : '';
329
+ const statusColor = { active: 'var(--success)', trialing: 'var(--warning)', past_due: 'var(--danger)', canceled: 'var(--text-muted)' }[sub.status] || 'var(--text-muted)';
246
330
  const periodEnd = sub.current_period_end ? sub.current_period_end.slice(0, 10) : '—';
247
331
  return `
248
332
  <tr>
@@ -255,9 +339,9 @@ function subRowHtml(sub) {
255
339
  }
256
340
 
257
341
  async function billingOverrideSub(userId) {
258
- if (!_billingPlans.length) { alert('No plans available.'); return; }
259
- const options = _billingPlans.filter((p) => p.is_active).map((p) => `${p.id} — ${p.name}`).join('\n');
260
- const planId = prompt(`Enter plan ID to assign:\n\n${options}`);
342
+ if (!_billingPlans.length) { showToast('No plans available.', 'error'); return; }
343
+ const activePlans = _billingPlans.filter((p) => p.is_active);
344
+ const planId = await billingPromptPlan(activePlans);
261
345
  if (!planId) return;
262
346
  try {
263
347
  const r = await fetch(`/admin/api/billing/users/${userId}/subscription`, {
@@ -266,13 +350,50 @@ async function billingOverrideSub(userId) {
266
350
  body: JSON.stringify({ planId: planId.trim() }),
267
351
  });
268
352
  const data = await r.json();
269
- if (!r.ok) { alert(data.error || 'Failed.'); return; }
353
+ if (!r.ok) { showToast(data.error || 'Failed.', 'error'); return; }
270
354
  await loadBillingSubscriptions();
271
355
  } catch (err) {
272
- alert('Error: ' + err.message);
356
+ showToast('Error: ' + err.message, 'error');
273
357
  }
274
358
  }
275
359
 
360
+ function billingPromptPlan(plans) {
361
+ return new Promise((resolve) => {
362
+ const overlay = document.createElement('div');
363
+ 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);';
364
+ const optionsHtml = plans.map((p) => `<option value="${escAttr(p.id)}">${esc(p.id)} — ${esc(p.name)}</option>`).join('');
365
+ const modal = document.createElement('div');
366
+ modal.style.cssText = 'width:400px;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);';
367
+ modal.innerHTML = `
368
+ <div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:12px;">Assign plan</div>
369
+ <div style="margin-bottom:16px;">
370
+ <select id="billing-plan-select" style="width:100%;padding:8px 10px;background:var(--bg-input,#111);border:1px solid var(--border,#2a2a2a);border-radius:6px;color:var(--text,#fff);font-size:13px;">
371
+ ${optionsHtml}
372
+ </select>
373
+ </div>
374
+ <div style="display:flex;gap:10px;justify-content:flex-end;">
375
+ <button class="btn btn-ghost" id="billing-plan-cancel" style="padding:8px 16px;">Cancel</button>
376
+ <button class="btn btn-primary" id="billing-plan-confirm" style="padding:8px 16px;">Assign</button>
377
+ </div>
378
+ `;
379
+ overlay.appendChild(modal);
380
+ document.body.appendChild(overlay);
381
+ const cancel = () => { overlay.remove(); resolve(null); };
382
+ const confirm = () => {
383
+ const val = modal.querySelector('#billing-plan-select').value;
384
+ overlay.remove();
385
+ resolve(val || null);
386
+ };
387
+ modal.querySelector('#billing-plan-cancel').onclick = cancel;
388
+ modal.querySelector('#billing-plan-confirm').onclick = confirm;
389
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
390
+ document.addEventListener('keydown', function handler(e) {
391
+ if (e.key === 'Escape') { cancel(); document.removeEventListener('keydown', handler); }
392
+ });
393
+ modal.querySelector('#billing-plan-confirm').focus();
394
+ });
395
+ }
396
+
276
397
  // ── Helpers ───────────────────────────────────────────────────────────────────
277
398
 
278
399
  function esc(str) {
@@ -282,7 +403,9 @@ function esc(str) {
282
403
  }
283
404
 
284
405
  function escAttr(str) {
285
- return esc(str);
406
+ // JSON.stringify gives us a properly quoted JS string; strip the outer quotes
407
+ // so it can be embedded in an HTML attribute value (already inside quotes).
408
+ return JSON.stringify(String(str ?? '')).slice(1, -1).replace(/"/g, '&quot;');
286
409
  }
287
410
 
288
411
  function fmtTokens(n) {
@@ -63,6 +63,7 @@
63
63
  width: 100%;
64
64
  text-align: left;
65
65
  text-decoration: none;
66
+ position: relative;
66
67
  }
67
68
 
68
69
  .nav-item:hover { background: var(--row-hover); color: var(--text); }
@@ -82,7 +83,10 @@
82
83
  .nav-item.active svg { opacity: 1; }
83
84
 
84
85
  .nav-badge {
85
- margin-left: auto;
86
+ position: absolute;
87
+ right: 10px;
88
+ top: 50%;
89
+ transform: translateY(-50%);
86
90
  min-width: 18px;
87
91
  padding: 1px 6px;
88
92
  border-radius: 999px;
@@ -835,6 +839,27 @@
835
839
  font-size: 12px;
836
840
  flex-shrink: 0;
837
841
  }
842
+
843
+ /* ── Integration sections ──────────────────────────────────────── */
844
+ .integration-section {
845
+ border-bottom: 1px solid var(--border);
846
+ padding: 16px 0;
847
+ }
848
+
849
+ .integration-section:last-child { border-bottom: none; }
850
+
851
+ .integration-header {
852
+ display: flex;
853
+ align-items: center;
854
+ gap: 10px;
855
+ margin-bottom: 14px;
856
+ }
857
+
858
+ .integration-name {
859
+ font-size: 13px;
860
+ font-weight: 700;
861
+ color: var(--text);
862
+ }
838
863
  </style>
839
864
  </head>
840
865
  <body>
@@ -871,7 +896,7 @@
871
896
 
872
897
  <button class="nav-item" data-page="issues" onclick="showPage('issues',this)">
873
898
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
874
- <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
899
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
875
900
  </svg>
876
901
  Issues
877
902
  <span class="nav-badge" id="issues-badge" hidden>0</span>
@@ -938,6 +963,13 @@
938
963
  Models
939
964
  </button>
940
965
 
966
+ <button class="nav-item" data-page="integrations" onclick="showPage('integrations',this)">
967
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
968
+ <path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
969
+ </svg>
970
+ Integrations
971
+ </button>
972
+
941
973
  <button class="nav-item" data-page="config" onclick="showPage('config',this)">
942
974
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
943
975
  <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
@@ -1177,15 +1209,53 @@
1177
1209
  </div>
1178
1210
  </section>
1179
1211
 
1212
+ <!-- Integrations -->
1213
+ <section id="page-integrations" class="page" aria-label="Integrations">
1214
+ <div class="page-header">
1215
+ <div>
1216
+ <h1 class="page-title">Integrations</h1>
1217
+ <p class="page-subtitle">OAuth client credentials for official integrations — saved to .env, applied immediately</p>
1218
+ </div>
1219
+ </div>
1220
+ <div class="content">
1221
+ <div class="card" style="margin-bottom:20px;">
1222
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
1223
+ <div class="card-title" style="margin-bottom:0;">OAuth Integrations</div>
1224
+ <button class="btn btn-primary" onclick="saveIntegrationsConfig(this)">Save All</button>
1225
+ </div>
1226
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:18px;line-height:1.5;">
1227
+ Redirect URIs should match <code style="font-family:var(--font-mono);font-size:11px;background:var(--bg-secondary);padding:1px 5px;border-radius:4px;color:var(--accent);">&lt;PUBLIC_URL&gt;/api/integrations/oauth/callback</code>
1228
+ </div>
1229
+ <div id="integrations-config-content"><div class="empty"><span class="spinner"></span></div></div>
1230
+ </div>
1231
+ <div class="card">
1232
+ <div class="card-title">Deepgram Configuration</div>
1233
+ <div id="deepgram-config-content"><div class="empty"><span class="spinner"></span></div></div>
1234
+ </div>
1235
+ </div>
1236
+ </section>
1237
+
1180
1238
  <!-- Configuration -->
1181
1239
  <section id="page-config" class="page" aria-label="Configuration">
1182
1240
  <div class="page-header">
1183
1241
  <div>
1184
1242
  <h1 class="page-title">Configuration</h1>
1185
- <p class="page-subtitle">Current environment settings (non-secret values only)</p>
1243
+ <p class="page-subtitle">Server and runtime settings saved to .env, applied immediately</p>
1186
1244
  </div>
1187
1245
  </div>
1188
1246
  <div class="content">
1247
+ <div class="card" style="margin-bottom:20px;">
1248
+ <div class="card-title">General Settings</div>
1249
+ <div id="general-config-content"><div class="empty"><span class="spinner"></span></div></div>
1250
+ </div>
1251
+ <div class="card" style="margin-bottom:20px;">
1252
+ <div class="card-title">VM Runtime</div>
1253
+ <div id="vm-config-content"><div class="empty"><span class="spinner"></span></div></div>
1254
+ </div>
1255
+ <div class="card" style="margin-bottom:20px;">
1256
+ <div class="card-title">Stripe / Billing</div>
1257
+ <div id="billing-setup-content"><div class="empty"><span class="spinner"></span></div></div>
1258
+ </div>
1189
1259
  <div class="card" style="margin-bottom:20px;">
1190
1260
  <div class="card-title">Service Email</div>
1191
1261
  <div id="email-config-content"><div class="empty"><span class="spinner"></span></div></div>
@@ -1198,8 +1268,13 @@
1198
1268
  </section>
1199
1269
 
1200
1270
  <section id="page-billing" class="page" aria-label="Billing">
1201
- <div class="page-header"><h1>Billing</h1></div>
1202
- <div class="cards">
1271
+ <div class="page-header">
1272
+ <div>
1273
+ <h1 class="page-title">Billing</h1>
1274
+ <p class="page-subtitle">Subscription plans and user billing management</p>
1275
+ </div>
1276
+ </div>
1277
+ <div class="content">
1203
1278
  <div class="card">
1204
1279
  <div class="card-title">Subscription Plans</div>
1205
1280
  <div style="margin-bottom:12px">
@@ -31,9 +31,9 @@ async function saveDefaultRateLimits(btn) {
31
31
  headers: { 'Content-Type': 'application/json' },
32
32
  body: JSON.stringify({ rate_limit_4h: parse(v4h), rate_limit_weekly: parse(vw) }),
33
33
  });
34
- if (!res.ok) { const b = await res.json().catch(() => ({})); alert(b.error || 'Failed'); }
34
+ if (!res.ok) { const b = await res.json().catch(() => ({})); showToast(b.error || 'Failed', 'error'); }
35
35
  else { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000); return; }
36
- } catch (_) { alert('Network error'); }
36
+ } catch (_) { showToast('Network error', 'error'); }
37
37
  btn.disabled = false;
38
38
  btn.textContent = orig;
39
39
  }
@@ -132,7 +132,7 @@ function renderUsersTable(el, users) {
132
132
  }
133
133
 
134
134
  async function forceLogout(id, username, btn) {
135
- if (!confirm(`Force logout ${username}?\n\nThis will revoke all active sessions. They can log back in.`)) return;
135
+ if (!await showConfirmModal({ title: `Force logout @${esc(username)}?`, body: 'This will revoke all active sessions. They can log back in.', confirmLabel: 'Force Logout', confirmClass: 'btn-danger' })) return;
136
136
  btn.disabled = true;
137
137
  try {
138
138
  const res = await api(`/admin/api/users/${id}/sessions`, { method: 'DELETE' });
@@ -141,22 +141,22 @@ async function forceLogout(id, username, btn) {
141
141
  setTimeout(loadUsers, 800);
142
142
  } else {
143
143
  const b = await res.json().catch(() => ({}));
144
- alert(b.error || 'Failed');
144
+ showToast(b.error || 'Failed', 'error');
145
145
  btn.disabled = false;
146
146
  }
147
147
  } catch (err) {
148
- if (err.message !== 'unauthorized') alert('Network error');
148
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
149
149
  btn.disabled = false;
150
150
  }
151
151
  }
152
152
 
153
153
  async function deleteUser(id, displayName, btn) {
154
- if (!confirm(
155
- `⚠ GDPR Erasure — permanently delete "${displayName}"?\n\n` +
156
- 'This will delete the account and ALL associated data:\n' +
157
- ' Agent runs, steps, and messages\n• Memories and conversations\n• Integrations and platform connections\n• Artifacts and files on disk\n• All sessions\n\n' +
158
- 'This action CANNOT be undone.'
159
- )) return;
154
+ if (!await showConfirmModal({
155
+ title: `Delete "${esc(displayName)}"?`,
156
+ body: `<strong>GDPR Erasure</strong> — permanently deletes the account and <strong>all</strong> associated data:<br><br>• Agent runs, steps, and messages<br>• Memories and conversations<br>• Integrations and platform connections<br>• Artifacts and files on disk<br>• All sessions<br><br><span style="color:var(--danger)">This action cannot be undone.</span>`,
157
+ confirmLabel: 'Delete User',
158
+ confirmClass: 'btn-danger',
159
+ })) return;
160
160
  btn.disabled = true;
161
161
  btn.textContent = 'Deleting…';
162
162
  try {
@@ -170,12 +170,12 @@ async function deleteUser(id, displayName, btn) {
170
170
  }
171
171
  } else {
172
172
  const b = await res.json().catch(() => ({}));
173
- alert(b.error || 'Failed to delete user');
173
+ showToast(b.error || 'Failed to delete user', 'error');
174
174
  btn.disabled = false;
175
175
  btn.textContent = 'Delete';
176
176
  }
177
177
  } catch (err) {
178
- if (err.message !== 'unauthorized') alert('Network error');
178
+ if (err.message !== 'unauthorized') showToast('Network error', 'error');
179
179
  btn.disabled = false;
180
180
  btn.textContent = 'Delete';
181
181
  }
@@ -266,12 +266,12 @@ async function editRateLimits(id, username) {
266
266
  overlay.remove();
267
267
  await fetchUsers(document.getElementById('user-search')?.value?.trim() || '');
268
268
  } catch (_) {
269
- alert('Failed to save rate limits');
269
+ showToast('Failed to save rate limits', 'error');
270
270
  bSave.disabled = false;
271
271
  bSave.textContent = 'Save';
272
272
  }
273
273
  };
274
274
  } catch (_) {
275
- alert('Failed to fetch rate limits');
275
+ showToast('Failed to fetch rate limits', 'error');
276
276
  }
277
277
  }