neoagent 2.4.2-beta.0 → 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.
@@ -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
+ }
@@ -4841,6 +4841,7 @@ class _TaskTriggerOption {
4841
4841
  required this.label,
4842
4842
  required this.description,
4843
4843
  required this.icon,
4844
+ this.providerKey,
4844
4845
  });
4845
4846
 
4846
4847
  final String type;
@@ -4848,6 +4849,11 @@ class _TaskTriggerOption {
4848
4849
  final String label;
4849
4850
  final String description;
4850
4851
  final IconData icon;
4852
+
4853
+ /// The official integration provider key this trigger binds to, if any.
4854
+ /// When set, the task editor shows a connected-account dropdown instead of
4855
+ /// a raw connection ID text field.
4856
+ final String? providerKey;
4851
4857
  }
4852
4858
 
4853
4859
  const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
@@ -4871,6 +4877,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4871
4877
  label: 'Gmail Message Received',
4872
4878
  description: 'Run when a matching Gmail message arrives.',
4873
4879
  icon: Icons.mail_rounded,
4880
+ providerKey: 'google_workspace',
4874
4881
  ),
4875
4882
  _TaskTriggerOption(
4876
4883
  type: 'outlook_email_received',
@@ -4878,6 +4885,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4878
4885
  label: 'Outlook Email Received',
4879
4886
  description: 'Run when a matching Outlook email arrives.',
4880
4887
  icon: Icons.markunread_rounded,
4888
+ providerKey: 'microsoft_365',
4881
4889
  ),
4882
4890
  _TaskTriggerOption(
4883
4891
  type: 'slack_message_received',
@@ -4885,6 +4893,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4885
4893
  label: 'Slack Message Received',
4886
4894
  description: 'Run when a Slack message matches the selected scope.',
4887
4895
  icon: Icons.forum_rounded,
4896
+ providerKey: 'slack',
4888
4897
  ),
4889
4898
  _TaskTriggerOption(
4890
4899
  type: 'teams_message_received',
@@ -4892,6 +4901,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4892
4901
  label: 'Teams Message Received',
4893
4902
  description: 'Run when a Teams chat message matches the selected scope.',
4894
4903
  icon: Icons.groups_rounded,
4904
+ providerKey: 'microsoft_365',
4895
4905
  ),
4896
4906
  _TaskTriggerOption(
4897
4907
  type: 'weather_event',
@@ -4900,6 +4910,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4900
4910
  description:
4901
4911
  'Run when configured weather events are forecast for a location.',
4902
4912
  icon: Icons.cloudy_snowing,
4913
+ providerKey: 'weather',
4903
4914
  ),
4904
4915
  _TaskTriggerOption(
4905
4916
  type: 'whatsapp_personal_message_received',
@@ -4907,6 +4918,7 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
4907
4918
  label: 'WhatsApp Personal Message Received',
4908
4919
  description: 'Run on inbound personal WhatsApp messages.',
4909
4920
  icon: Icons.chat_bubble_rounded,
4921
+ providerKey: 'whatsapp_personal',
4910
4922
  ),
4911
4923
  ];
4912
4924
 
@@ -4917,6 +4929,7 @@ _TaskTriggerOption _taskTriggerOptionForType(String type) {
4917
4929
  );
4918
4930
  }
4919
4931
 
4932
+
4920
4933
  Future<String?> _pickTaskTriggerType(
4921
4934
  BuildContext context,
4922
4935
  String selectedType,
@@ -5562,6 +5575,62 @@ class _TasksPanelState extends State<TasksPanel> {
5562
5575
  );
5563
5576
  }
5564
5577
 
5578
+ List<OfficialIntegrationAccountItem> _connectedAccountsForTrigger(
5579
+ String triggerType,
5580
+ ) {
5581
+ final providerKey = _taskTriggerOptionForType(triggerType).providerKey;
5582
+ if (providerKey == null) return const <OfficialIntegrationAccountItem>[];
5583
+ final seen = <int>{};
5584
+ final result = <OfficialIntegrationAccountItem>[];
5585
+ for (final integration in controller.officialIntegrations) {
5586
+ if (integration.id != providerKey) continue;
5587
+ for (final app in integration.apps) {
5588
+ for (final account in app.accounts) {
5589
+ if (account.connected && seen.add(account.id)) {
5590
+ result.add(account);
5591
+ }
5592
+ }
5593
+ }
5594
+ }
5595
+ return result;
5596
+ }
5597
+
5598
+ Widget _buildConnectionIdSelector({
5599
+ required String triggerType,
5600
+ required ValueNotifier<int?> selectedConnectionId,
5601
+ required TextEditingController fallbackController,
5602
+ required StateSetter setLocalState,
5603
+ }) {
5604
+ final accounts = _connectedAccountsForTrigger(triggerType);
5605
+ if (accounts.isEmpty) {
5606
+ return TextField(
5607
+ controller: fallbackController,
5608
+ keyboardType: TextInputType.number,
5609
+ decoration: const InputDecoration(
5610
+ labelText: 'Connection ID',
5611
+ helperText: 'Connect this integration first to pick an account.',
5612
+ ),
5613
+ );
5614
+ }
5615
+ return DropdownButtonFormField<int>(
5616
+ value: accounts.any((a) => a.id == selectedConnectionId.value)
5617
+ ? selectedConnectionId.value
5618
+ : null,
5619
+ isExpanded: true,
5620
+ decoration: const InputDecoration(labelText: 'Account'),
5621
+ items: accounts
5622
+ .map(
5623
+ (account) => DropdownMenuItem<int>(
5624
+ value: account.id,
5625
+ child: Text(account.accountEmail ?? 'Account #${account.id}'),
5626
+ ),
5627
+ )
5628
+ .toList(),
5629
+ onChanged: (value) =>
5630
+ setLocalState(() => selectedConnectionId.value = value),
5631
+ );
5632
+ }
5633
+
5565
5634
  Future<void> _openTaskEditor(
5566
5635
  BuildContext context, {
5567
5636
  TaskItem? task,
@@ -5578,6 +5647,13 @@ class _TasksPanelState extends State<TasksPanel> {
5578
5647
  final connectionIdController = TextEditingController(
5579
5648
  text: task?.triggerConfig['connectionId']?.toString() ?? '',
5580
5649
  );
5650
+ final selectedConnectionId = ValueNotifier<int?>(
5651
+ task?.triggerConfig['connectionId'] is int
5652
+ ? task!.triggerConfig['connectionId'] as int
5653
+ : int.tryParse(
5654
+ task?.triggerConfig['connectionId']?.toString() ?? '',
5655
+ ),
5656
+ );
5581
5657
  final queryController = TextEditingController(
5582
5658
  text:
5583
5659
  task?.triggerConfig['query']?.toString() ??
@@ -5659,6 +5735,8 @@ class _TasksPanelState extends State<TasksPanel> {
5659
5735
  );
5660
5736
  if (nextType != null) {
5661
5737
  triggerType.value = nextType;
5738
+ selectedConnectionId.value = null;
5739
+ connectionIdController.clear();
5662
5740
  }
5663
5741
  },
5664
5742
  child: InputDecorator(
@@ -5778,12 +5856,11 @@ class _TasksPanelState extends State<TasksPanel> {
5778
5856
 
5779
5857
  return Column(
5780
5858
  children: <Widget>[
5781
- TextField(
5782
- controller: connectionIdController,
5783
- decoration: const InputDecoration(
5784
- labelText:
5785
- 'Official Integration Connection ID',
5786
- ),
5859
+ _buildConnectionIdSelector(
5860
+ triggerType: selectedTriggerType,
5861
+ selectedConnectionId: selectedConnectionId,
5862
+ fallbackController: connectionIdController,
5863
+ setLocalState: setLocalState,
5787
5864
  ),
5788
5865
  const SizedBox(height: 12),
5789
5866
  if (selectedTriggerType ==
@@ -5961,15 +6038,15 @@ class _TasksPanelState extends State<TasksPanel> {
5961
6038
  triggerConfig['runAt'] = runAt;
5962
6039
  }
5963
6040
  } else {
5964
- final parsedConnectionId = int.tryParse(
5965
- connectionIdController.text.trim(),
5966
- );
6041
+ final parsedConnectionId =
6042
+ selectedConnectionId.value ??
6043
+ int.tryParse(connectionIdController.text.trim());
5967
6044
  if (parsedConnectionId == null ||
5968
6045
  parsedConnectionId <= 0) {
5969
6046
  ScaffoldMessenger.of(context).showSnackBar(
5970
6047
  const SnackBar(
5971
6048
  content: Text(
5972
- 'Connection ID must be a positive integer.',
6049
+ 'Please select an account or enter a valid connection ID.',
5973
6050
  ),
5974
6051
  backgroundColor: Colors.red,
5975
6052
  ),
@@ -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,
@@ -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, .footer-legal { display: flex; flex-direction: column; gap: 9px; }
793
- .footer-nav a, .footer-legal a { font-size: 14.5px; color: var(--ink-2); transition: color .2s; }
794
- .footer-nav a:hover, .footer-legal a:hover { color: var(--gold); }
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 &amp; 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 &amp; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.2-beta.0",
3
+ "version": "2.4.2-beta.2",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -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() {