neoagent 2.4.2-beta.2 → 2.4.2-beta.4

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.
@@ -639,64 +639,105 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
639
639
  );
640
640
  }
641
641
 
642
- Widget buildStatBox(String label, int current, int? limit) {
642
+ Widget buildStatBox(String label, int current, int? limit, {bool isCustom = false}) {
643
643
  final double progress = limit != null
644
644
  ? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
645
645
  : 0.0;
646
646
  final bool nearLimit = progress > 0.8;
647
-
647
+ final bool atLimit = progress >= 1.0;
648
+
648
649
  return Container(
649
650
  width: double.infinity,
650
651
  padding: const EdgeInsets.all(20),
651
652
  decoration: BoxDecoration(
652
653
  color: _bgSecondary,
653
654
  borderRadius: BorderRadius.circular(16),
654
- border: Border.all(color: nearLimit ? _warning.withValues(alpha: 0.5) : _borderLight),
655
+ border: Border.all(
656
+ color: atLimit
657
+ ? _danger.withValues(alpha: 0.6)
658
+ : nearLimit
659
+ ? _warning.withValues(alpha: 0.5)
660
+ : _borderLight,
661
+ ),
655
662
  ),
656
663
  child: Column(
657
664
  crossAxisAlignment: CrossAxisAlignment.start,
658
665
  children: <Widget>[
659
- Text(
660
- label,
661
- style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
666
+ Row(
667
+ children: <Widget>[
668
+ Text(label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
669
+ const Spacer(),
670
+ if (limit != null)
671
+ Container(
672
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
673
+ decoration: BoxDecoration(
674
+ color: isCustom
675
+ ? _warning.withValues(alpha: 0.12)
676
+ : _accent.withValues(alpha: 0.10),
677
+ borderRadius: BorderRadius.circular(20),
678
+ ),
679
+ child: Text(
680
+ isCustom ? 'custom' : 'default',
681
+ style: TextStyle(
682
+ fontSize: 11,
683
+ fontWeight: FontWeight.w600,
684
+ color: isCustom ? _warning : _accent,
685
+ ),
686
+ ),
687
+ ),
688
+ ],
662
689
  ),
663
690
  const SizedBox(height: 12),
664
691
  Row(
692
+ crossAxisAlignment: CrossAxisAlignment.end,
665
693
  children: <Widget>[
666
694
  Text(
667
695
  _formatTokens(current),
668
696
  style: TextStyle(
669
697
  fontSize: 28,
670
698
  fontWeight: FontWeight.w800,
671
- color: nearLimit ? _warning : null,
699
+ color: atLimit ? _danger : nearLimit ? _warning : null,
672
700
  ),
673
701
  ),
674
- Text(
675
- ' tokens used',
676
- style: TextStyle(color: _textSecondary, fontSize: 14),
702
+ Padding(
703
+ padding: const EdgeInsets.only(bottom: 3),
704
+ child: Text(
705
+ ' tokens used',
706
+ style: TextStyle(color: _textSecondary, fontSize: 14),
707
+ ),
677
708
  ),
678
709
  const Spacer(),
679
710
  if (limit != null)
680
711
  Text(
681
- 'Limit: ${_formatTokens(limit)}',
712
+ 'of ${_formatTokens(limit)}',
682
713
  style: TextStyle(color: _textMuted, fontSize: 13),
683
714
  )
684
715
  else
685
716
  Text(
686
- 'No limit set',
717
+ 'No limit',
687
718
  style: TextStyle(color: _textMuted, fontSize: 13),
688
719
  ),
689
720
  ],
690
721
  ),
691
722
  if (limit != null) ...<Widget>[
692
- const SizedBox(height: 16),
723
+ const SizedBox(height: 14),
693
724
  ClipRRect(
694
725
  borderRadius: BorderRadius.circular(999),
695
726
  child: LinearProgressIndicator(
696
727
  minHeight: 8,
697
728
  value: progress,
698
729
  backgroundColor: _border,
699
- valueColor: AlwaysStoppedAnimation<Color>(nearLimit ? _warning : _accent),
730
+ valueColor: AlwaysStoppedAnimation<Color>(
731
+ atLimit ? _danger : nearLimit ? _warning : _accent,
732
+ ),
733
+ ),
734
+ ),
735
+ const SizedBox(height: 6),
736
+ Text(
737
+ '${(progress * 100).toStringAsFixed(0)}% used',
738
+ style: TextStyle(
739
+ fontSize: 11,
740
+ color: atLimit ? _danger : nearLimit ? _warning : _textMuted,
700
741
  ),
701
742
  ),
702
743
  ],
@@ -715,9 +756,9 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
715
756
  style: TextStyle(color: _textSecondary, height: 1.4),
716
757
  ),
717
758
  const SizedBox(height: 24),
718
- buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit),
759
+ buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit, isCustom: usage.fourHourIsCustom),
719
760
  const SizedBox(height: 16),
720
- buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit),
761
+ buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit, isCustom: usage.weeklyIsCustom),
721
762
  ],
722
763
  );
723
764
  }
@@ -4083,6 +4083,8 @@ class AccountUsageAndLimits {
4083
4083
  this.weeklyLimit,
4084
4084
  this.fourHourUsage = 0,
4085
4085
  this.weeklyUsage = 0,
4086
+ this.fourHourIsCustom = false,
4087
+ this.weeklyIsCustom = false,
4086
4088
  });
4087
4089
 
4088
4090
  factory AccountUsageAndLimits.fromJson(Map<String, dynamic> json) {
@@ -4093,6 +4095,8 @@ class AccountUsageAndLimits {
4093
4095
  weeklyLimit: int.tryParse(limits['weekly']?.toString() ?? ''),
4094
4096
  fourHourUsage: _asInt(usage['fourHour']),
4095
4097
  weeklyUsage: _asInt(usage['weekly']),
4098
+ fourHourIsCustom: limits['fourHourIsCustom'] == true,
4099
+ weeklyIsCustom: limits['weeklyIsCustom'] == true,
4096
4100
  );
4097
4101
  }
4098
4102
 
@@ -4100,4 +4104,6 @@ class AccountUsageAndLimits {
4100
4104
  final int? weeklyLimit;
4101
4105
  final int fourHourUsage;
4102
4106
  final int weeklyUsage;
4107
+ final bool fourHourIsCustom;
4108
+ final bool weeklyIsCustom;
4103
4109
  }
@@ -1015,7 +1015,7 @@ p { margin: 0; text-wrap: pretty; }
1015
1015
  <div class="frame">
1016
1016
  <div class="frame-bar">
1017
1017
  <span class="dot" style="background:#e26e61"></span><span class="dot" style="background:#e0b24a"></span><span class="dot" style="background:#4bbf85"></span>
1018
- <span class="url">app.neoagent.ai · control surface</span>
1018
+ <span class="url">control surface</span>
1019
1019
  </div>
1020
1020
  <div class="frame-body shot-pair">
1021
1021
  <img class="lm" src="images/dashboard-light.png" alt="NeoAgent dashboard" loading="eager">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.2-beta.2",
3
+ "version": "2.4.2-beta.4",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -328,6 +328,27 @@ async function clearProvider(key, btn) {
328
328
 
329
329
  // ── Models ─────────────────────────────────────────────────────────────────
330
330
 
331
+ function fmtModelPrice(inputCostPerM) {
332
+ if (inputCostPerM === null || inputCostPerM === undefined) return '—';
333
+ if (inputCostPerM === 0) return 'Free';
334
+ if (inputCostPerM < 0.01) return `$${inputCostPerM.toFixed(4)}/M`;
335
+ if (inputCostPerM < 1) return `$${inputCostPerM.toFixed(3)}/M`;
336
+ return `$${inputCostPerM.toFixed(2)}/M`;
337
+ }
338
+
339
+ function priceTierClass(tier) {
340
+ if (tier === 'free') return 'badge-ok';
341
+ if (tier === 'cheap') return 'badge-ok';
342
+ if (tier === 'medium') return 'badge-warn';
343
+ if (tier === 'expensive') return 'badge-err';
344
+ return 'badge-idle';
345
+ }
346
+
347
+ function purposeIcon(purpose) {
348
+ const icons = { fast: '⚡', planning: '🧠', coding: '💻', general: '✦' };
349
+ return icons[purpose] || '✦';
350
+ }
351
+
331
352
  async function loadModels() {
332
353
  const el = document.getElementById('models-content');
333
354
  if (!el) return;
@@ -335,43 +356,61 @@ async function loadModels() {
335
356
  const data = await api('/admin/api/models').then((r) => r.json());
336
357
  const models = data.models || [];
337
358
  const enabledModels = data.enabledModels || [];
338
-
359
+
339
360
  if (!models.length) { el.innerHTML = '<div class="empty">No models found</div>'; return; }
340
-
341
- // Sort models by provider, then label
361
+
362
+ // Group by provider for better readability
342
363
  models.sort((a, b) => {
343
364
  if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
344
- return a.label.localeCompare(b.label);
365
+ const ac = a.inputCostPerM ?? Infinity;
366
+ const bc = b.inputCostPerM ?? Infinity;
367
+ return ac - bc;
345
368
  });
346
-
347
- let html = `<table class="users-table">
369
+
370
+ const enabledCount = enabledModels.length === 0 ? models.length : enabledModels.length;
371
+
372
+ let html = `
373
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
374
+ <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(true)">Select All</button>
375
+ <button class="btn btn-ghost" style="padding:5px 12px;font-size:12px;" onclick="toggleAllModels(false)">Select None</button>
376
+ <span style="margin-left:4px;font-size:12px;color:var(--text-muted);" id="model-count-label">${enabledCount} of ${models.length} enabled</span>
377
+ </div>
378
+ <table class="users-table">
348
379
  <thead><tr>
349
- <th style="width:40px;">Enabled</th>
350
- <th>Model Name</th>
380
+ <th style="width:40px;">On</th>
381
+ <th>Model</th>
351
382
  <th>Provider</th>
352
- <th>Purpose</th>
353
- <th>Price Tier</th>
383
+ <th style="width:90px;">Purpose</th>
384
+ <th style="width:80px;">Tier</th>
385
+ <th style="width:110px;text-align:right;">Input / 1M tokens</th>
354
386
  </tr></thead>
355
387
  <tbody>`;
356
-
388
+
357
389
  for (const m of models) {
358
- // Checked if it's in the enabledModels list, OR if the list is empty (all enabled)
359
390
  const isChecked = enabledModels.length === 0 || enabledModels.includes(m.id);
360
- const rowOpacity = isChecked ? '1' : '0.5';
391
+ const rowOpacity = isChecked ? '1' : '0.45';
392
+ const priceStr = fmtModelPrice(m.inputCostPerM);
393
+ const tierCls = priceTierClass(m.priceTier);
394
+ const icon = purposeIcon(m.purpose);
361
395
  html += `
362
- <tr style="opacity: ${rowOpacity}">
396
+ <tr style="opacity:${rowOpacity}" id="model-row-${esc(m.id)}">
363
397
  <td style="text-align:center;">
364
- <input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isChecked ? 'checked' : ''} onchange="this.closest('tr').style.opacity = this.checked ? '1' : '0.5'">
398
+ <input type="checkbox" class="model-cb" value="${esc(m.id)}" ${isChecked ? 'checked' : ''}
399
+ onchange="onModelToggle(this)">
365
400
  </td>
366
- <td style="font-weight:600;color:var(--text);">${esc(m.label)}</td>
367
- <td>${esc(m.provider)}</td>
368
- <td><span class="badge badge-idle">${esc(m.purpose)}</span></td>
369
- <td><span class="badge ${m.priceTier === 'free' ? 'badge-ok' : 'badge-idle'}">${esc(m.priceTier)}</span></td>
401
+ <td>
402
+ <div style="font-weight:600;color:var(--text);font-size:13px;">${esc(m.label)}</div>
403
+ <div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);margin-top:2px;">${esc(m.id)}</div>
404
+ </td>
405
+ <td style="font-size:13px;text-transform:capitalize;">${esc(m.provider)}</td>
406
+ <td><span class="badge badge-idle" style="gap:4px;">${icon} ${esc(m.purpose)}</span></td>
407
+ <td><span class="badge ${tierCls}">${esc(m.priceTier ?? '?')}</span></td>
408
+ <td style="text-align:right;font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--text);">${priceStr}</td>
370
409
  </tr>
371
410
  `;
372
411
  }
373
-
374
- html += \`</tbody></table>\`;
412
+
413
+ html += `</tbody></table>`;
375
414
  el.innerHTML = html;
376
415
  } catch (err) {
377
416
  if (err.message !== 'unauthorized') {
@@ -380,6 +419,26 @@ async function loadModels() {
380
419
  }
381
420
  }
382
421
 
422
+ function onModelToggle(cb) {
423
+ const row = cb.closest('tr');
424
+ if (row) row.style.opacity = cb.checked ? '1' : '0.45';
425
+ const all = document.querySelectorAll('.model-cb');
426
+ const checked = Array.from(all).filter(c => c.checked).length;
427
+ const label = document.getElementById('model-count-label');
428
+ if (label) label.textContent = `${checked} of ${all.length} enabled`;
429
+ }
430
+
431
+ function toggleAllModels(enable) {
432
+ document.querySelectorAll('.model-cb').forEach(cb => {
433
+ cb.checked = enable;
434
+ const row = cb.closest('tr');
435
+ if (row) row.style.opacity = enable ? '1' : '0.45';
436
+ });
437
+ const all = document.querySelectorAll('.model-cb');
438
+ const label = document.getElementById('model-count-label');
439
+ if (label) label.textContent = `${enable ? all.length : 0} of ${all.length} enabled`;
440
+ }
441
+
383
442
  async function saveEnabledModels(btn) {
384
443
  const cbs = document.querySelectorAll('.model-cb');
385
444
  if (!cbs.length) return;
@@ -934,6 +934,33 @@
934
934
  <span class="toolbar-count" id="users-count" aria-live="polite" style="padding-top:6px;"></span>
935
935
  </div>
936
936
  <div class="content">
937
+
938
+ <!-- Default rate limits card -->
939
+ <div class="card" style="margin-bottom:20px;">
940
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;">
941
+ <div>
942
+ <div style="font-size:15px;font-weight:700;color:var(--text);margin-bottom:4px;">Default Rate Limits</div>
943
+ <div style="font-size:12px;color:var(--text-muted);max-width:480px;">
944
+ Applied to every user unless they have a custom override. Leave empty for no limit.
945
+ </div>
946
+ </div>
947
+ <button class="btn btn-primary" style="flex-shrink:0;" onclick="saveDefaultRateLimits(this)">Save Defaults</button>
948
+ </div>
949
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;" id="default-limits-fields">
950
+ <div>
951
+ <label style="display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);margin-bottom:6px;">4-Hour Limit (tokens)</label>
952
+ <input type="number" min="0" step="1" id="default-limit-4h" placeholder="e.g. 500000 — empty = no limit"
953
+ style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
954
+ </div>
955
+ <div>
956
+ <label style="display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);margin-bottom:6px;">Weekly Limit (tokens)</label>
957
+ <input type="number" min="0" step="1" id="default-limit-weekly" placeholder="e.g. 2000000 — empty = no limit"
958
+ style="width:100%;padding:9px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
959
+ </div>
960
+ </div>
961
+ </div>
962
+
963
+ <!-- Users table card -->
937
964
  <div class="card">
938
965
  <div class="toolbar" style="margin-bottom:14px;">
939
966
  <input type="search" id="user-search" placeholder="Search by username or email…"
@@ -5,8 +5,37 @@
5
5
  let _userSearchTimer = null;
6
6
 
7
7
  async function loadUsers() {
8
- const q = document.getElementById('user-search')?.value?.trim() || '';
9
- await fetchUsers(q);
8
+ await Promise.all([loadDefaultRateLimits(), fetchUsers(document.getElementById('user-search')?.value?.trim() || '')]);
9
+ }
10
+
11
+ async function loadDefaultRateLimits() {
12
+ try {
13
+ const data = await api('/admin/api/config/rate-limits').then(r => r.json());
14
+ const f4h = document.getElementById('default-limit-4h');
15
+ const fw = document.getElementById('default-limit-weekly');
16
+ if (f4h) f4h.value = data.rate_limit_4h ?? '';
17
+ if (fw) fw.value = data.rate_limit_weekly ?? '';
18
+ } catch (_) {}
19
+ }
20
+
21
+ async function saveDefaultRateLimits(btn) {
22
+ const v4h = document.getElementById('default-limit-4h')?.value;
23
+ const vw = document.getElementById('default-limit-weekly')?.value;
24
+ const parse = (v) => (v !== '' && v != null) ? parseInt(v, 10) : null;
25
+ btn.disabled = true;
26
+ const orig = btn.textContent;
27
+ btn.textContent = 'Saving…';
28
+ try {
29
+ const res = await api('/admin/api/config/rate-limits', {
30
+ method: 'PUT',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ rate_limit_4h: parse(v4h), rate_limit_weekly: parse(vw) }),
33
+ });
34
+ if (!res.ok) { const b = await res.json().catch(() => ({})); alert(b.error || 'Failed'); }
35
+ else { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000); return; }
36
+ } catch (_) { alert('Network error'); }
37
+ btn.disabled = false;
38
+ btn.textContent = orig;
10
39
  }
11
40
 
12
41
  async function fetchUsers(q = '') {
@@ -46,6 +75,7 @@ function renderUsersTable(el, users) {
46
75
  <th>Last Login</th>
47
76
  <th>Runs</th>
48
77
  <th>Storage</th>
78
+ <th>Rate Limits</th>
49
79
  <th style="text-align:right;">Actions</th>
50
80
  </tr></thead>
51
81
  <tbody>${users.map((u) => `
@@ -69,6 +99,14 @@ function renderUsersTable(el, users) {
69
99
  <td style="font-size:12px;color:var(--text-muted);">${u.last_login ? fmtDate(u.last_login) : '—'}</td>
70
100
  <td style="font-family:var(--font-mono);font-size:12px;">${fmtNum(u.run_count)}</td>
71
101
  <td style="font-family:var(--font-mono);font-size:12px;">${fmtBytes(u.storage_bytes)}</td>
102
+ <td style="font-size:11px;">
103
+ ${u.rate_limit_4h || u.rate_limit_weekly
104
+ ? `<div style="display:flex;flex-direction:column;gap:3px;">
105
+ ${u.rate_limit_4h ? `<span class="badge badge-idle" style="font-size:10px;">4h: ${fmtTokens(u.rate_limit_4h)}</span>` : ''}
106
+ ${u.rate_limit_weekly ? `<span class="badge badge-idle" style="font-size:10px;">7d: ${fmtTokens(u.rate_limit_weekly)}</span>` : ''}
107
+ </div>`
108
+ : `<span style="color:var(--text-muted);">none</span>`}
109
+ </td>
72
110
  <td>
73
111
  <div style="display:flex;gap:6px;justify-content:flex-end;">
74
112
  <button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
@@ -143,6 +181,13 @@ async function deleteUser(id, displayName, btn) {
143
181
  }
144
182
  }
145
183
 
184
+ function fmtTokens(n) {
185
+ if (!n) return '—';
186
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
187
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
188
+ return String(n);
189
+ }
190
+
146
191
  function fmtDate(iso) {
147
192
  if (!iso) return '—';
148
193
  try {
@@ -152,69 +197,81 @@ function fmtDate(iso) {
152
197
 
153
198
  async function editRateLimits(id, username) {
154
199
  try {
155
- const res = await api(`/admin/api/users/${id}/rate-limits`).then(r => r.json());
156
- const limits = res.limits || {};
157
- const limit4h = limits.rate_limit_4h || '';
158
- const limitWeekly = limits.rate_limit_weekly || '';
159
-
200
+ const [userRes, globalRes] = await Promise.all([
201
+ api(`/admin/api/users/${id}/rate-limits`).then(r => r.json()),
202
+ api('/admin/api/config/rate-limits').then(r => r.json()).catch(() => ({})),
203
+ ]);
204
+ const limits = userRes.limits || {};
205
+ const custom4h = limits.rate_limit_4h ?? null;
206
+ const customWeekly = limits.rate_limit_weekly ?? null;
207
+ const global4h = globalRes.rate_limit_4h ?? null;
208
+ const globalWeekly = globalRes.rate_limit_weekly ?? null;
209
+
210
+ const hint4h = global4h ? `Default: ${fmtTokens(global4h)} — empty inherits default` : 'Empty = no limit';
211
+ const hintWeekly = globalWeekly ? `Default: ${fmtTokens(globalWeekly)} — empty inherits default` : 'Empty = no limit';
212
+
160
213
  const overlay = document.createElement('div');
161
214
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;backdrop-filter:blur(2px);';
162
215
  const modal = document.createElement('div');
163
216
  modal.className = 'card';
164
- modal.style.cssText = 'width:420px;background:var(--bg-primary);box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid var(--border);border-radius:12px;padding:24px;';
217
+ modal.style.cssText = 'width:440px;background:var(--bg-primary);box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid var(--border);border-radius:12px;padding:24px;';
165
218
  modal.innerHTML = `
166
- <div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:6px;">Rate Limits</div>
167
- <div style="font-size:13px;color:var(--text-muted);margin-bottom:20px;">Configure token limits for <span style="color:var(--text);font-weight:600;">@${esc(username)}</span>.</div>
168
-
169
- <div>
170
- <label style="display:block;font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text);text-transform:uppercase;letter-spacing:0.05em;">4-Hour Limit (Tokens)</label>
171
- <input type="number" min="0" step="1" id="limit-4h" value="${limit4h}" placeholder="e.g. 500000" style="width:100%;padding:10px 12px;margin-bottom:6px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);">
172
- <div style="font-size:11px;color:var(--text-muted);margin-bottom:20px;">Leave empty for no limit</div>
173
-
174
- <label style="display:block;font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text);text-transform:uppercase;letter-spacing:0.05em;">Weekly Limit (Tokens)</label>
175
- <input type="number" min="0" step="1" id="limit-weekly" value="${limitWeekly}" placeholder="e.g. 2000000" style="width:100%;padding:10px 12px;margin-bottom:6px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);">
176
- <div style="font-size:11px;color:var(--text-muted);margin-bottom:24px;">Leave empty for no limit</div>
219
+ <div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:4px;">Rate Limits</div>
220
+ <div style="font-size:13px;color:var(--text-muted);margin-bottom:20px;">
221
+ Custom overrides for <span style="color:var(--text);font-weight:600;">@${esc(username)}</span>.
222
+ Leave empty to inherit the global default.
177
223
  </div>
178
-
224
+
225
+ <div style="margin-bottom:18px;">
226
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
227
+ <label style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">4-Hour Limit (tokens)</label>
228
+ ${custom4h !== null ? '<span class="badge badge-warn" style="font-size:10px;">custom</span>' : (global4h ? '<span class="badge badge-idle" style="font-size:10px;">inheriting default</span>' : '<span class="badge badge-idle" style="font-size:10px;">no limit</span>')}
229
+ </div>
230
+ <input type="number" min="0" step="1" id="limit-4h" value="${custom4h ?? ''}" placeholder="${hint4h}"
231
+ style="width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
232
+ </div>
233
+
234
+ <div style="margin-bottom:24px;">
235
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
236
+ <label style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">Weekly Limit (tokens)</label>
237
+ ${customWeekly !== null ? '<span class="badge badge-warn" style="font-size:10px;">custom</span>' : (globalWeekly ? '<span class="badge badge-idle" style="font-size:10px;">inheriting default</span>' : '<span class="badge badge-idle" style="font-size:10px;">no limit</span>')}
238
+ </div>
239
+ <input type="number" min="0" step="1" id="limit-weekly" value="${customWeekly ?? ''}" placeholder="${hintWeekly}"
240
+ style="width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
241
+ </div>
242
+
179
243
  <div style="display:flex;gap:12px;justify-content:flex-end;">
180
244
  <button class="btn btn-ghost" id="btn-cancel" style="padding:8px 16px;">Cancel</button>
181
- <button class="btn btn-primary" id="btn-save" style="padding:8px 16px;">Save Limits</button>
245
+ <button class="btn btn-primary" id="btn-save" style="padding:8px 16px;">Save</button>
182
246
  </div>
183
247
  `;
184
248
  overlay.appendChild(modal);
185
249
  document.body.appendChild(overlay);
186
-
250
+
187
251
  document.getElementById('btn-cancel').onclick = () => overlay.remove();
188
252
  document.getElementById('btn-save').onclick = async () => {
189
253
  const v4 = document.getElementById('limit-4h').value;
190
254
  const vw = document.getElementById('limit-weekly').value;
191
- const parseLimit = (v) => {
192
- if (!v) return null;
193
- const n = Number(v);
194
- return Number.isInteger(n) && n >= 0 ? n : null;
195
- };
255
+ const parseLimit = (v) => { if (!v) return null; const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : null; };
196
256
  const bSave = document.getElementById('btn-save');
197
257
  bSave.disabled = true;
198
- bSave.textContent = 'Saving...';
258
+ bSave.textContent = 'Saving';
199
259
  try {
200
- const payload = {
201
- rate_limit_4h: parseLimit(v4),
202
- rate_limit_weekly: parseLimit(vw)
203
- };
204
260
  const putRes = await api(`/admin/api/users/${id}/rate-limits`, {
205
261
  method: 'PUT',
206
- headers: {'Content-Type': 'application/json'},
207
- body: JSON.stringify(payload)
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ rate_limit_4h: parseLimit(v4), rate_limit_weekly: parseLimit(vw) }),
208
264
  });
209
265
  if (!putRes.ok) throw new Error('Save failed');
210
266
  overlay.remove();
211
- } catch (err) {
267
+ await fetchUsers(document.getElementById('user-search')?.value?.trim() || '');
268
+ } catch (_) {
212
269
  alert('Failed to save rate limits');
213
270
  bSave.disabled = false;
214
- bSave.textContent = 'Save Limits';
271
+ bSave.textContent = 'Save';
215
272
  }
216
273
  };
217
- } catch (err) {
274
+ } catch (_) {
218
275
  alert('Failed to fetch rate limits');
219
276
  }
220
277
  }
@@ -1 +1 @@
1
- 69851219011d2a9c760c214e28b850a0
1
+ 4783d0646db3c87d4fbb189103797925
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "3511630357" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "2939524330" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });