neoagent 2.4.2-beta.1 → 2.4.2-beta.2
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.
- package/flutter_app/lib/main_account_settings.dart +131 -1
- package/flutter_app/lib/main_controller.dart +5 -0
- package/flutter_app/lib/main_models.dart +24 -0
- package/flutter_app/lib/src/backend_client.dart +4 -0
- package/landing/index.html +3 -43
- package/package.json +1 -1
- package/server/admin/admin.js +88 -1
- package/server/admin/index.html +25 -5
- package/server/admin/users.js +73 -0
- package/server/db/database.js +17 -0
- package/server/http/static.js +0 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +52123 -52040
- package/server/routes/account.js +21 -0
- package/server/routes/admin.js +40 -1
- package/server/services/ai/engine.js +16 -0
- package/server/services/ai/models.js +10 -1
|
@@ -123,7 +123,7 @@ class _PasswordStrengthIndicator extends StatelessWidget {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
enum AccountSettingsTab { account, security }
|
|
126
|
+
enum AccountSettingsTab { account, usage, security }
|
|
127
127
|
|
|
128
128
|
class AccountSettingsPanel extends StatefulWidget {
|
|
129
129
|
const AccountSettingsPanel({
|
|
@@ -355,6 +355,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
|
|
|
355
355
|
switch (_selectedTab) {
|
|
356
356
|
case AccountSettingsTab.account:
|
|
357
357
|
return _buildAccountPanel();
|
|
358
|
+
case AccountSettingsTab.usage:
|
|
359
|
+
return _buildUsagePanel();
|
|
358
360
|
case AccountSettingsTab.security:
|
|
359
361
|
return _buildSecurityPanel();
|
|
360
362
|
}
|
|
@@ -593,6 +595,133 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
|
|
|
593
595
|
);
|
|
594
596
|
}
|
|
595
597
|
|
|
598
|
+
String _formatTokens(int amount) {
|
|
599
|
+
if (amount >= 1000000) {
|
|
600
|
+
final value = amount / 1000000;
|
|
601
|
+
return '${value == value.truncateToDouble() ? value.toInt() : value.toStringAsFixed(1)}M';
|
|
602
|
+
}
|
|
603
|
+
if (amount >= 1000) {
|
|
604
|
+
final value = amount / 1000;
|
|
605
|
+
return '${value == value.truncateToDouble() ? value.toInt() : value.toStringAsFixed(1)}k';
|
|
606
|
+
}
|
|
607
|
+
return amount.toString();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
Widget _buildUsagePanel() {
|
|
611
|
+
if (widget.controller.isLoadingAccountSettings) {
|
|
612
|
+
return const Center(
|
|
613
|
+
child: Padding(
|
|
614
|
+
padding: EdgeInsets.all(40),
|
|
615
|
+
child: CircularProgressIndicator(),
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
final usage = widget.controller.usageAndLimits;
|
|
621
|
+
if (usage == null) {
|
|
622
|
+
return Center(
|
|
623
|
+
child: Padding(
|
|
624
|
+
padding: const EdgeInsets.all(40),
|
|
625
|
+
child: Column(
|
|
626
|
+
mainAxisSize: MainAxisSize.min,
|
|
627
|
+
children: <Widget>[
|
|
628
|
+
Icon(Icons.error_outline, size: 48, color: _textSecondary),
|
|
629
|
+
const SizedBox(height: 16),
|
|
630
|
+
const Text('Could not load usage data.'),
|
|
631
|
+
const SizedBox(height: 16),
|
|
632
|
+
FilledButton(
|
|
633
|
+
onPressed: widget.controller.refreshAccountSettings,
|
|
634
|
+
child: const Text('Retry'),
|
|
635
|
+
),
|
|
636
|
+
],
|
|
637
|
+
),
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
Widget buildStatBox(String label, int current, int? limit) {
|
|
643
|
+
final double progress = limit != null
|
|
644
|
+
? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
|
|
645
|
+
: 0.0;
|
|
646
|
+
final bool nearLimit = progress > 0.8;
|
|
647
|
+
|
|
648
|
+
return Container(
|
|
649
|
+
width: double.infinity,
|
|
650
|
+
padding: const EdgeInsets.all(20),
|
|
651
|
+
decoration: BoxDecoration(
|
|
652
|
+
color: _bgSecondary,
|
|
653
|
+
borderRadius: BorderRadius.circular(16),
|
|
654
|
+
border: Border.all(color: nearLimit ? _warning.withValues(alpha: 0.5) : _borderLight),
|
|
655
|
+
),
|
|
656
|
+
child: Column(
|
|
657
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
658
|
+
children: <Widget>[
|
|
659
|
+
Text(
|
|
660
|
+
label,
|
|
661
|
+
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
662
|
+
),
|
|
663
|
+
const SizedBox(height: 12),
|
|
664
|
+
Row(
|
|
665
|
+
children: <Widget>[
|
|
666
|
+
Text(
|
|
667
|
+
_formatTokens(current),
|
|
668
|
+
style: TextStyle(
|
|
669
|
+
fontSize: 28,
|
|
670
|
+
fontWeight: FontWeight.w800,
|
|
671
|
+
color: nearLimit ? _warning : null,
|
|
672
|
+
),
|
|
673
|
+
),
|
|
674
|
+
Text(
|
|
675
|
+
' tokens used',
|
|
676
|
+
style: TextStyle(color: _textSecondary, fontSize: 14),
|
|
677
|
+
),
|
|
678
|
+
const Spacer(),
|
|
679
|
+
if (limit != null)
|
|
680
|
+
Text(
|
|
681
|
+
'Limit: ${_formatTokens(limit)}',
|
|
682
|
+
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
683
|
+
)
|
|
684
|
+
else
|
|
685
|
+
Text(
|
|
686
|
+
'No limit set',
|
|
687
|
+
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
688
|
+
),
|
|
689
|
+
],
|
|
690
|
+
),
|
|
691
|
+
if (limit != null) ...<Widget>[
|
|
692
|
+
const SizedBox(height: 16),
|
|
693
|
+
ClipRRect(
|
|
694
|
+
borderRadius: BorderRadius.circular(999),
|
|
695
|
+
child: LinearProgressIndicator(
|
|
696
|
+
minHeight: 8,
|
|
697
|
+
value: progress,
|
|
698
|
+
backgroundColor: _border,
|
|
699
|
+
valueColor: AlwaysStoppedAnimation<Color>(nearLimit ? _warning : _accent),
|
|
700
|
+
),
|
|
701
|
+
),
|
|
702
|
+
],
|
|
703
|
+
],
|
|
704
|
+
),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return Column(
|
|
709
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
710
|
+
children: <Widget>[
|
|
711
|
+
const _SectionTitle('Usage & Limits'),
|
|
712
|
+
const SizedBox(height: 12),
|
|
713
|
+
Text(
|
|
714
|
+
'Keep track of your AI usage. Limits are enforced to ensure fair usage across the platform.',
|
|
715
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
716
|
+
),
|
|
717
|
+
const SizedBox(height: 24),
|
|
718
|
+
buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit),
|
|
719
|
+
const SizedBox(height: 16),
|
|
720
|
+
buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit),
|
|
721
|
+
],
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
596
725
|
Widget _buildSecurityPanel() {
|
|
597
726
|
final controller = widget.controller;
|
|
598
727
|
final twoFactorEnabled = controller.accountTwoFactor['enabled'] == true;
|
|
@@ -958,6 +1087,7 @@ class _AccountSettingsTabs extends StatelessWidget {
|
|
|
958
1087
|
Widget build(BuildContext context) {
|
|
959
1088
|
final buttons = <Widget>[
|
|
960
1089
|
_tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
|
|
1090
|
+
_tabButton(AccountSettingsTab.usage, Icons.data_usage_outlined, 'Usage & Limits'),
|
|
961
1091
|
_tabButton(
|
|
962
1092
|
AccountSettingsTab.security,
|
|
963
1093
|
Icons.security_outlined,
|
|
@@ -134,6 +134,7 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
134
134
|
Map<String, dynamic>? user;
|
|
135
135
|
Map<String, dynamic> accountTwoFactor = const <String, dynamic>{};
|
|
136
136
|
List<AccountSessionItem> accountSessions = const <AccountSessionItem>[];
|
|
137
|
+
AccountUsageAndLimits? usageAndLimits;
|
|
137
138
|
List<AuthProviderCatalogItem> authProviders =
|
|
138
139
|
const <AuthProviderCatalogItem>[];
|
|
139
140
|
List<LinkedAuthProviderItem> linkedAuthProviders =
|
|
@@ -1658,6 +1659,7 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
1658
1659
|
user = null;
|
|
1659
1660
|
accountTwoFactor = const <String, dynamic>{};
|
|
1660
1661
|
accountSessions = const <AccountSessionItem>[];
|
|
1662
|
+
usageAndLimits = null;
|
|
1661
1663
|
linkedAuthProviders = const <LinkedAuthProviderItem>[];
|
|
1662
1664
|
settings = const <String, dynamic>{};
|
|
1663
1665
|
chatMessages = const <ChatEntry>[];
|
|
@@ -4924,6 +4926,9 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
4924
4926
|
backendUrl,
|
|
4925
4927
|
);
|
|
4926
4928
|
_applyAccountResponse(sessionsResponse);
|
|
4929
|
+
usageAndLimits = AccountUsageAndLimits.fromJson(
|
|
4930
|
+
await _backendClient.fetchAccountUsage(backendUrl),
|
|
4931
|
+
);
|
|
4927
4932
|
} catch (error) {
|
|
4928
4933
|
errorMessage = _friendlyErrorMessage(error);
|
|
4929
4934
|
} finally {
|
|
@@ -4077,3 +4077,27 @@ class ChatRichPayload {
|
|
|
4077
4077
|
final List<ChatPayloadOption> options;
|
|
4078
4078
|
}
|
|
4079
4079
|
|
|
4080
|
+
class AccountUsageAndLimits {
|
|
4081
|
+
const AccountUsageAndLimits({
|
|
4082
|
+
this.fourHourLimit,
|
|
4083
|
+
this.weeklyLimit,
|
|
4084
|
+
this.fourHourUsage = 0,
|
|
4085
|
+
this.weeklyUsage = 0,
|
|
4086
|
+
});
|
|
4087
|
+
|
|
4088
|
+
factory AccountUsageAndLimits.fromJson(Map<String, dynamic> json) {
|
|
4089
|
+
final limits = json['limits'] is Map ? json['limits'] as Map : const {};
|
|
4090
|
+
final usage = json['usage'] is Map ? json['usage'] as Map : const {};
|
|
4091
|
+
return AccountUsageAndLimits(
|
|
4092
|
+
fourHourLimit: int.tryParse(limits['fourHour']?.toString() ?? ''),
|
|
4093
|
+
weeklyLimit: int.tryParse(limits['weekly']?.toString() ?? ''),
|
|
4094
|
+
fourHourUsage: _asInt(usage['fourHour']),
|
|
4095
|
+
weeklyUsage: _asInt(usage['weekly']),
|
|
4096
|
+
);
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
final int? fourHourLimit;
|
|
4100
|
+
final int? weeklyLimit;
|
|
4101
|
+
final int fourHourUsage;
|
|
4102
|
+
final int weeklyUsage;
|
|
4103
|
+
}
|
|
@@ -224,6 +224,10 @@ class BackendClient {
|
|
|
224
224
|
return getMap(baseUrl, '/api/account');
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
Future<Map<String, dynamic>> fetchAccountUsage(String baseUrl) async {
|
|
228
|
+
return getMap(baseUrl, '/api/account/usage');
|
|
229
|
+
}
|
|
230
|
+
|
|
227
231
|
Future<Map<String, dynamic>> updateAccountEmail({
|
|
228
232
|
required String baseUrl,
|
|
229
233
|
required String email,
|
package/landing/index.html
CHANGED
|
@@ -762,18 +762,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
762
762
|
.feature-list .chk { width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; background: rgba(95,168,151,0.16); color: var(--teal); }
|
|
763
763
|
.feature-list .chk svg { width: 13px; height: 13px; }
|
|
764
764
|
|
|
765
|
-
/* ---------- privacy ---------- */
|
|
766
|
-
.privacy-card { border-radius: 26px; background: var(--wash-b); border: 1px solid var(--line); padding: clamp(30px, 5vw, 64px); display: grid; grid-template-columns: 1.05fr 1fr; gap: clamp(28px, 4vw, 48px); align-items: center; }
|
|
767
|
-
.privacy-card h2 { font-size: clamp(28px, 4vw, 46px); font-weight: 450; margin: 14px 0 16px; }
|
|
768
|
-
.privacy-card > div > p { color: var(--ink-2); font-size: 17px; margin-bottom: 22px; }
|
|
769
|
-
.privacy-list { display: grid; gap: 12px; }
|
|
770
|
-
.privacy-list .row { display: flex; gap: 14px; align-items: flex-start; padding: 16px 18px; border-radius: 15px; background: var(--surface); border: 1px solid var(--line); transition: transform .25s var(--ease), box-shadow .25s var(--ease); }
|
|
771
|
-
.privacy-list .row:hover { transform: translateX(4px); box-shadow: var(--shadow-sm); }
|
|
772
|
-
.privacy-list .ico { width: 34px; height: 34px; flex-shrink: 0; border-radius: 10px; display: grid; place-items: center; background: rgba(95,168,151,0.16); color: var(--teal); }
|
|
773
|
-
.privacy-list .ico svg { width: 18px; height: 18px; }
|
|
774
|
-
.privacy-list b { display: block; font-weight: 700; font-size: 15.5px; margin-bottom: 3px; color: var(--ink); }
|
|
775
|
-
.privacy-list span { font-size: 14px; color: var(--ink-3); }
|
|
776
|
-
|
|
777
765
|
/* ---------- final CTA ---------- */
|
|
778
766
|
.final { position: relative; text-align: center; overflow: hidden; border-radius: 30px; background: var(--wash-a); border: 1px solid var(--line); padding: clamp(52px, 9vw, 110px) var(--gutter); }
|
|
779
767
|
.final h2 { font-size: clamp(32px, 5.4vw, 70px); font-weight: 440; margin-bottom: 20px; }
|
|
@@ -789,9 +777,9 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
789
777
|
.footer-left { flex: 1; min-width: 180px; }
|
|
790
778
|
.footer-left .brand { margin-bottom: 10px; }
|
|
791
779
|
.footer-left p { color: var(--ink-3); font-size: 14px; max-width: 220px; }
|
|
792
|
-
.footer-nav
|
|
793
|
-
.footer-nav a
|
|
794
|
-
.footer-nav a:hover
|
|
780
|
+
.footer-nav { display: flex; flex-direction: column; gap: 9px; }
|
|
781
|
+
.footer-nav a { font-size: 14.5px; color: var(--ink-2); transition: color .2s; }
|
|
782
|
+
.footer-nav a:hover { color: var(--gold); }
|
|
795
783
|
.footer-bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding-top: 20px; border-top: 1px solid var(--line); font-size: 13px; color: var(--ink-3); }
|
|
796
784
|
.footer-bar .ops { display: inline-flex; align-items: center; gap: 7px; color: var(--green); font-weight: 500; }
|
|
797
785
|
.footer-bar .ops .d { width: 7px; height: 7px; border-radius: 50%; background: var(--green); }
|
|
@@ -832,7 +820,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
832
820
|
.mobile-menu .btn { margin-top: 12px; justify-content: center; }
|
|
833
821
|
.split { grid-template-columns: 1fr; gap: 32px; }
|
|
834
822
|
.split.rev .split-visual { order: 0; }
|
|
835
|
-
.privacy-card { grid-template-columns: 1fr; }
|
|
836
823
|
.hero { padding-top: 120px; }
|
|
837
824
|
}
|
|
838
825
|
@media (max-width: 620px) {
|
|
@@ -900,7 +887,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
900
887
|
<a href="#features">Features</a>
|
|
901
888
|
<a href="#channels">Channels</a>
|
|
902
889
|
<a href="#how">How it works</a>
|
|
903
|
-
<a href="#privacy">Privacy</a>
|
|
904
890
|
</nav>
|
|
905
891
|
<div class="nav-right">
|
|
906
892
|
<a class="signin" href="/app">Sign in</a>
|
|
@@ -914,7 +900,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
914
900
|
<a href="#features">Features</a>
|
|
915
901
|
<a href="#channels">Channels</a>
|
|
916
902
|
<a href="#how">How it works</a>
|
|
917
|
-
<a href="#privacy">Privacy</a>
|
|
918
903
|
<a class="btn btn-primary" href="#cta">Get early access</a>
|
|
919
904
|
</div>
|
|
920
905
|
</header>
|
|
@@ -1066,14 +1051,12 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
1066
1051
|
</div>
|
|
1067
1052
|
<div class="mtrack rev" style="--mdur:52s">
|
|
1068
1053
|
<div class="mrow">
|
|
1069
|
-
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"></path></svg>Privacy-first</span>
|
|
1070
1054
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="15" rx="2"></rect><path d="M8 21h8M12 18v3" stroke-linecap="round"></path></svg>Remote browser</span>
|
|
1071
1055
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2L12 3Z" stroke-linejoin="round"></path></svg>Automations</span>
|
|
1072
1056
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a5 5 0 0 0-5 5c0 1-1 1.5-1 3a4 4 0 0 0 4 4v3M12 3a5 5 0 0 1 5 5c0 1 1 1.5 1 3a4 4 0 0 1-4 4v3" stroke-linecap="round"></path></svg>Memory</span>
|
|
1073
1057
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="2"></circle><circle cx="19" cy="5" r="2"></circle><circle cx="19" cy="19" r="2"></circle><path d="m7 12 10-6M7 12l10 6" stroke-linecap="round"></path></svg>Skills</span>
|
|
1074
1058
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 7a4 4 0 0 0-5.4 5.4L4 17l3 3 4.6-4.6A4 4 0 0 0 17 10l-2.5 2.5L12 10l2.5-2.5Z" stroke-linejoin="round"></path></svg>Tools & MCP</span>
|
|
1075
1059
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"></path></svg>Recordings</span>
|
|
1076
|
-
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"></path></svg>Privacy-first</span>
|
|
1077
1060
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="15" rx="2"></rect><path d="M8 21h8M12 18v3" stroke-linecap="round"></path></svg>Remote browser</span>
|
|
1078
1061
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2L12 3Z" stroke-linejoin="round"></path></svg>Automations</span>
|
|
1079
1062
|
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a5 5 0 0 0-5 5c0 1-1 1.5-1 3a4 4 0 0 0 4 4v3M12 3a5 5 0 0 1 5 5c0 1 1 1.5 1 3a4 4 0 0 1-4 4v3" stroke-linecap="round"></path></svg>Memory</span>
|
|
@@ -1210,27 +1193,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
1210
1193
|
</div>
|
|
1211
1194
|
</section>
|
|
1212
1195
|
|
|
1213
|
-
<!-- =================== PRIVACY =================== -->
|
|
1214
|
-
<section class="section-tight" id="privacy" data-screen-label="privacy">
|
|
1215
|
-
<div class="wrap">
|
|
1216
|
-
<div class="privacy-card reveal">
|
|
1217
|
-
<div>
|
|
1218
|
-
<span class="eyebrow" style="display:block;margin-bottom:14px;">Private by design</span>
|
|
1219
|
-
<h2>Your data. Your rules.</h2>
|
|
1220
|
-
<p>Run NeoAgent in our EU cloud or host it yourself — either way it's GDPR-first from the ground up. Nothing leaves your control unless you allow it.</p>
|
|
1221
|
-
<a class="btn btn-soft" href="legal.html" style="margin-top:4px;">Read our privacy & legal notice
|
|
1222
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
|
1223
|
-
</a>
|
|
1224
|
-
</div>
|
|
1225
|
-
<div class="privacy-list">
|
|
1226
|
-
<div class="row reveal" data-d="1"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3 4 6v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V6l-8-3Z" stroke-linejoin="round"></path></svg></span><div><b>Cloud or self-hosted</b><span>EU cloud or your own hardware — your choice.</span></div></div>
|
|
1227
|
-
<div class="row reveal" data-d="2"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round"></path></svg></span><div><b>GDPR-first</b><span>Access, portability, and the right to erasure built in.</span></div></div>
|
|
1228
|
-
<div class="row reveal" data-d="3"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v18M5 8l7-5 7 5M5 8v8l7 5 7-5V8" stroke-linejoin="round"></path></svg></span><div><b>Transparent</b><span>Every action logged. No hidden behavior.</span></div></div>
|
|
1229
|
-
</div>
|
|
1230
|
-
</div>
|
|
1231
|
-
</div>
|
|
1232
|
-
</section>
|
|
1233
|
-
|
|
1234
1196
|
<!-- =================== FINAL CTA =================== -->
|
|
1235
1197
|
<section class="section-tight" id="cta" data-screen-label="cta">
|
|
1236
1198
|
<div class="wrap">
|
|
@@ -1275,8 +1237,6 @@ p { margin: 0; text-wrap: pretty; }
|
|
|
1275
1237
|
<a href="https://neolabs-systems.github.io/NeoAgent/docs/">Documentation</a>
|
|
1276
1238
|
</nav>
|
|
1277
1239
|
<nav class="footer-legal">
|
|
1278
|
-
<a href="legal.html#overview">Privacy Policy</a>
|
|
1279
|
-
<a href="legal.html#legal-notice">Legal Notice</a>
|
|
1280
1240
|
<a href="#">Contact</a>
|
|
1281
1241
|
</nav>
|
|
1282
1242
|
</div>
|
package/package.json
CHANGED
package/server/admin/admin.js
CHANGED
|
@@ -16,7 +16,7 @@ function showPage(page, btn) {
|
|
|
16
16
|
if (btn) btn.classList.add('active');
|
|
17
17
|
currentPage = page;
|
|
18
18
|
|
|
19
|
-
const loaders = { overview: loadHealth, logs: loadLogs, updates: loadVersion, config: loadConfig, providers: loadProviders, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
|
|
19
|
+
const loaders = { overview: loadHealth, logs: loadLogs, updates: loadVersion, config: loadConfig, providers: loadProviders, models: loadModels, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
|
|
20
20
|
loaders[page]?.();
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -326,6 +326,93 @@ async function clearProvider(key, btn) {
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
// ── Models ─────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
async function loadModels() {
|
|
332
|
+
const el = document.getElementById('models-content');
|
|
333
|
+
if (!el) return;
|
|
334
|
+
try {
|
|
335
|
+
const data = await api('/admin/api/models').then((r) => r.json());
|
|
336
|
+
const models = data.models || [];
|
|
337
|
+
const enabledModels = data.enabledModels || [];
|
|
338
|
+
|
|
339
|
+
if (!models.length) { el.innerHTML = '<div class="empty">No models found</div>'; return; }
|
|
340
|
+
|
|
341
|
+
// Sort models by provider, then label
|
|
342
|
+
models.sort((a, b) => {
|
|
343
|
+
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
344
|
+
return a.label.localeCompare(b.label);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let html = `<table class="users-table">
|
|
348
|
+
<thead><tr>
|
|
349
|
+
<th style="width:40px;">Enabled</th>
|
|
350
|
+
<th>Model Name</th>
|
|
351
|
+
<th>Provider</th>
|
|
352
|
+
<th>Purpose</th>
|
|
353
|
+
<th>Price Tier</th>
|
|
354
|
+
</tr></thead>
|
|
355
|
+
<tbody>`;
|
|
356
|
+
|
|
357
|
+
for (const m of models) {
|
|
358
|
+
// Checked if it's in the enabledModels list, OR if the list is empty (all enabled)
|
|
359
|
+
const isChecked = enabledModels.length === 0 || enabledModels.includes(m.id);
|
|
360
|
+
const rowOpacity = isChecked ? '1' : '0.5';
|
|
361
|
+
html += `
|
|
362
|
+
<tr style="opacity: ${rowOpacity}">
|
|
363
|
+
<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'">
|
|
365
|
+
</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>
|
|
370
|
+
</tr>
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
html += \`</tbody></table>\`;
|
|
375
|
+
el.innerHTML = html;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err.message !== 'unauthorized') {
|
|
378
|
+
el.innerHTML = '<div class="empty">Failed to load models</div>';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function saveEnabledModels(btn) {
|
|
384
|
+
const cbs = document.querySelectorAll('.model-cb');
|
|
385
|
+
if (!cbs.length) return;
|
|
386
|
+
|
|
387
|
+
// If all are checked, we can save an empty list to mean "all enabled"
|
|
388
|
+
const allChecked = Array.from(cbs).every(cb => cb.checked);
|
|
389
|
+
const enabledModels = allChecked ? [] : Array.from(cbs).filter(cb => cb.checked).map(cb => cb.value);
|
|
390
|
+
|
|
391
|
+
btn.disabled = true;
|
|
392
|
+
const original = btn.textContent;
|
|
393
|
+
btn.textContent = 'Saving…';
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const res = await api('/admin/api/models/enabled', {
|
|
397
|
+
method: 'PUT',
|
|
398
|
+
headers: { 'Content-Type': 'application/json' },
|
|
399
|
+
body: JSON.stringify({ enabledModels }),
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok) {
|
|
402
|
+
const body = await res.json().catch(() => ({}));
|
|
403
|
+
alert(body.error || 'Failed to save');
|
|
404
|
+
} else {
|
|
405
|
+
btn.textContent = 'Saved!';
|
|
406
|
+
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err.message !== 'unauthorized') alert('Network error');
|
|
411
|
+
}
|
|
412
|
+
btn.disabled = false;
|
|
413
|
+
btn.textContent = original;
|
|
414
|
+
}
|
|
415
|
+
|
|
329
416
|
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
|
330
417
|
|
|
331
418
|
function startPolling() {
|
package/server/admin/index.html
CHANGED
|
@@ -819,6 +819,13 @@
|
|
|
819
819
|
Providers
|
|
820
820
|
</button>
|
|
821
821
|
|
|
822
|
+
<button class="nav-item" data-page="models" onclick="showPage('models',this)">
|
|
823
|
+
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
824
|
+
<path fill-rule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm8-6a6 6 0 100 12 6 6 0 000-12zm-3 7a1 1 0 011-1h4a1 1 0 011 1v1a1 1 0 01-1 1H8a1 1 0 01-1-1v-1z" clip-rule="evenodd"/>
|
|
825
|
+
</svg>
|
|
826
|
+
Models
|
|
827
|
+
</button>
|
|
828
|
+
|
|
822
829
|
<button class="nav-item" data-page="config" onclick="showPage('config',this)">
|
|
823
830
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
824
831
|
<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"/>
|
|
@@ -828,11 +835,6 @@
|
|
|
828
835
|
</nav>
|
|
829
836
|
|
|
830
837
|
<div class="sidebar-footer">
|
|
831
|
-
<div class="sidebar-legal">
|
|
832
|
-
<a href="/legal.html#overview" target="_blank" rel="noopener">Privacy Policy</a>
|
|
833
|
-
<span>·</span>
|
|
834
|
-
<a href="/legal.html#legal-notice" target="_blank" rel="noopener">Legal Notice</a>
|
|
835
|
-
</div>
|
|
836
838
|
<button class="nav-item btn-danger" onclick="signOut()">
|
|
837
839
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
838
840
|
<path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd"/>
|
|
@@ -988,6 +990,24 @@
|
|
|
988
990
|
</div>
|
|
989
991
|
</section>
|
|
990
992
|
|
|
993
|
+
<!-- Models -->
|
|
994
|
+
<section id="page-models" class="page" aria-label="Models">
|
|
995
|
+
<div class="page-header">
|
|
996
|
+
<div>
|
|
997
|
+
<h1 class="page-title">AI Models</h1>
|
|
998
|
+
<p class="page-subtitle">Enable or disable specific AI models globally for all users.</p>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="content">
|
|
1002
|
+
<div class="card">
|
|
1003
|
+
<div class="toolbar" style="margin-bottom:14px; justify-content: flex-end;">
|
|
1004
|
+
<button class="btn btn-primary" onclick="saveEnabledModels(this)">Save Model Configuration</button>
|
|
1005
|
+
</div>
|
|
1006
|
+
<div id="models-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>
|
|
1009
|
+
</section>
|
|
1010
|
+
|
|
991
1011
|
<!-- Configuration -->
|
|
992
1012
|
<section id="page-config" class="page" aria-label="Configuration">
|
|
993
1013
|
<div class="page-header">
|
package/server/admin/users.js
CHANGED
|
@@ -71,6 +71,10 @@ function renderUsersTable(el, users) {
|
|
|
71
71
|
<td style="font-family:var(--font-mono);font-size:12px;">${fmtBytes(u.storage_bytes)}</td>
|
|
72
72
|
<td>
|
|
73
73
|
<div style="display:flex;gap:6px;justify-content:flex-end;">
|
|
74
|
+
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
|
|
75
|
+
onclick="editRateLimits('${esc(u.id)}','${esc(u.username)}')" title="Edit Rate Limits">
|
|
76
|
+
Limits
|
|
77
|
+
</button>
|
|
74
78
|
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
|
|
75
79
|
onclick="forceLogout('${esc(u.id)}','${esc(u.username)}',this)" title="Revoke all sessions">
|
|
76
80
|
Logout
|
|
@@ -145,3 +149,72 @@ function fmtDate(iso) {
|
|
|
145
149
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
146
150
|
} catch { return iso; }
|
|
147
151
|
}
|
|
152
|
+
|
|
153
|
+
async function editRateLimits(id, username) {
|
|
154
|
+
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
|
+
|
|
160
|
+
const overlay = document.createElement('div');
|
|
161
|
+
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
|
+
const modal = document.createElement('div');
|
|
163
|
+
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;';
|
|
165
|
+
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>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div style="display:flex;gap:12px;justify-content:flex-end;">
|
|
180
|
+
<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>
|
|
182
|
+
</div>
|
|
183
|
+
`;
|
|
184
|
+
overlay.appendChild(modal);
|
|
185
|
+
document.body.appendChild(overlay);
|
|
186
|
+
|
|
187
|
+
document.getElementById('btn-cancel').onclick = () => overlay.remove();
|
|
188
|
+
document.getElementById('btn-save').onclick = async () => {
|
|
189
|
+
const v4 = document.getElementById('limit-4h').value;
|
|
190
|
+
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
|
+
};
|
|
196
|
+
const bSave = document.getElementById('btn-save');
|
|
197
|
+
bSave.disabled = true;
|
|
198
|
+
bSave.textContent = 'Saving...';
|
|
199
|
+
try {
|
|
200
|
+
const payload = {
|
|
201
|
+
rate_limit_4h: parseLimit(v4),
|
|
202
|
+
rate_limit_weekly: parseLimit(vw)
|
|
203
|
+
};
|
|
204
|
+
const putRes = await api(`/admin/api/users/${id}/rate-limits`, {
|
|
205
|
+
method: 'PUT',
|
|
206
|
+
headers: {'Content-Type': 'application/json'},
|
|
207
|
+
body: JSON.stringify(payload)
|
|
208
|
+
});
|
|
209
|
+
if (!putRes.ok) throw new Error('Save failed');
|
|
210
|
+
overlay.remove();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
alert('Failed to save rate limits');
|
|
213
|
+
bSave.disabled = false;
|
|
214
|
+
bSave.textContent = 'Save Limits';
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
} catch (err) {
|
|
218
|
+
alert('Failed to fetch rate limits');
|
|
219
|
+
}
|
|
220
|
+
}
|
package/server/db/database.js
CHANGED
|
@@ -1862,6 +1862,23 @@ function migrateUsersDisplayName() {
|
|
|
1862
1862
|
}
|
|
1863
1863
|
migrateUsersDisplayName();
|
|
1864
1864
|
|
|
1865
|
+
function migrateUserRateLimits() {
|
|
1866
|
+
try {
|
|
1867
|
+
const columns = db.pragma('table_info(users)');
|
|
1868
|
+
const hasRateLimit4h = columns.some((c) => c.name === 'rate_limit_4h');
|
|
1869
|
+
if (!hasRateLimit4h) {
|
|
1870
|
+
db.exec('ALTER TABLE users ADD COLUMN rate_limit_4h INTEGER');
|
|
1871
|
+
}
|
|
1872
|
+
const hasRateLimitWeekly = columns.some((c) => c.name === 'rate_limit_weekly');
|
|
1873
|
+
if (!hasRateLimitWeekly) {
|
|
1874
|
+
db.exec('ALTER TABLE users ADD COLUMN rate_limit_weekly INTEGER');
|
|
1875
|
+
}
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
console.warn('Could not add rate_limit columns:', err.message);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
migrateUserRateLimits();
|
|
1881
|
+
|
|
1865
1882
|
function migrateIngestionDocumentsConnectionId() {
|
|
1866
1883
|
// connection_id was initially nullable; NULL breaks the UNIQUE dedup constraint.
|
|
1867
1884
|
// Coerce any existing NULLs to 0 so the unique index works as intended.
|
package/server/http/static.js
CHANGED
|
@@ -80,7 +80,6 @@ function registerStaticRoutes(app) {
|
|
|
80
80
|
// Landing page at /
|
|
81
81
|
app.use(express.static(LANDING_DIR));
|
|
82
82
|
app.get('/', (req, res) => res.sendFile(path.join(LANDING_DIR, 'index.html')));
|
|
83
|
-
app.get('/legal.html', (req, res) => res.sendFile(path.join(LANDING_DIR, 'legal.html')));
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
function serveFlutterApp(req, res) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
69851219011d2a9c760c214e28b850a0
|
|
Binary file
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "3511630357" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|