neoagent 2.4.0 → 2.4.1-beta.11

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 (57) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/background.mjs +19 -7
  4. package/extensions/chrome-browser/icons/icon128.png +0 -0
  5. package/extensions/chrome-browser/icons/icon16.png +0 -0
  6. package/extensions/chrome-browser/icons/icon48.png +0 -0
  7. package/extensions/chrome-browser/icons/logo.svg +12 -0
  8. package/extensions/chrome-browser/manifest.json +13 -2
  9. package/extensions/chrome-browser/popup.css +5 -0
  10. package/extensions/chrome-browser/popup.html +7 -5
  11. package/extensions/chrome-browser/popup.js +16 -7
  12. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +721 -0
  13. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
  14. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
  15. package/flutter_app/lib/main.dart +1 -0
  16. package/flutter_app/lib/main_controller.dart +156 -3
  17. package/flutter_app/lib/main_devices.dart +485 -119
  18. package/flutter_app/lib/main_settings.dart +289 -30
  19. package/flutter_app/lib/src/backend_client.dart +89 -0
  20. package/flutter_app/lib/src/desktop_companion_actions.dart +153 -3
  21. package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
  22. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  23. package/flutter_app/lib/src/stream_renderer.dart +286 -0
  24. package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
  25. package/package.json +2 -2
  26. package/server/guest_agent.js +19 -1
  27. package/server/http/routes.js +191 -0
  28. package/server/http/socket.js +1 -1
  29. package/server/index.js +4 -1
  30. package/server/public/.last_build_id +1 -1
  31. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  32. package/server/public/flutter_bootstrap.js +1 -1
  33. package/server/public/main.dart.js +75438 -74005
  34. package/server/routes/browser.js +14 -0
  35. package/server/routes/browser_extension.js +21 -4
  36. package/server/routes/desktop.js +10 -0
  37. package/server/routes/settings.js +4 -0
  38. package/server/routes/stream.js +187 -0
  39. package/server/services/ai/tools.js +40 -29
  40. package/server/services/android/controller.js +41 -2
  41. package/server/services/browser/controller.js +34 -0
  42. package/server/services/browser/extension/manifest.js +33 -0
  43. package/server/services/browser/extension/provider.js +12 -6
  44. package/server/services/browser/extension/registry.js +188 -18
  45. package/server/services/desktop/gateway.js +28 -3
  46. package/server/services/desktop/protocol.js +34 -0
  47. package/server/services/desktop/provider.js +25 -0
  48. package/server/services/desktop/registry.js +92 -10
  49. package/server/services/manager.js +19 -2
  50. package/server/services/runtime/backends/local-vm.js +6 -0
  51. package/server/services/runtime/docker-vm-manager.js +26 -3
  52. package/server/services/runtime/manager.js +36 -5
  53. package/server/services/runtime/settings.js +17 -0
  54. package/server/services/streaming/android-stream.js +298 -0
  55. package/server/services/streaming/browser-stream.js +87 -0
  56. package/server/services/streaming/stream-hub.js +231 -0
  57. package/server/services/websocket.js +73 -0
@@ -170,7 +170,9 @@ const List<_SettingsSection> _settingsSearchSections = <_SettingsSection>[
170
170
  class _SettingsPanelState extends State<SettingsPanel> {
171
171
  late final TextEditingController _searchController;
172
172
  late String _browserBackend;
173
+ String? _browserExtensionTokenId;
173
174
  late String _cliBackend;
175
+ String? _cliDesktopDeviceId;
174
176
  late bool _smarterSelector;
175
177
  late Set<String> _enabledModels;
176
178
  late String _defaultChatModel;
@@ -187,6 +189,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
189
  <String, TextEditingController>{};
188
190
  final Set<String> _expandedProviderIds = <String>{};
189
191
 
192
+ // Inline runtime test state — ephemeral, not stored in controller.
193
+ bool _cliTestRunning = false;
194
+ Map<String, dynamic>? _cliTestResult;
195
+ bool _extensionTestRunning = false;
196
+ Map<String, dynamic>? _extensionTestResult;
197
+ bool _desktopTestRunning = false;
198
+ Map<String, dynamic>? _desktopTestResult;
199
+
190
200
  @override
191
201
  void initState() {
192
202
  super.initState();
@@ -224,7 +234,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
224
234
  .map((model) => model.id)
225
235
  .toSet();
226
236
  _browserBackend = _normalizeBrowserBackend(controller.browserBackend);
237
+ _browserExtensionTokenId =
238
+ controller.browserExtensionTokenId ??
239
+ controller.selectedBrowserExtensionTokenId;
227
240
  _cliBackend = _normalizeCliBackend(controller.cliBackend);
241
+ _cliDesktopDeviceId = controller.cliDesktopDeviceId;
228
242
  _smarterSelector = controller.smarterSelector;
229
243
  _enabledModels = controller.enabledModelIds
230
244
  .where((id) => knownModels.contains(id))
@@ -466,7 +480,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
466
480
  browserBackend: _browserBackend == 'extension'
467
481
  ? 'extension'
468
482
  : 'vm',
483
+ browserExtensionTokenId: _browserBackend == 'extension'
484
+ ? _browserExtensionTokenId
485
+ : null,
469
486
  cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
487
+ cliDesktopDeviceId: _cliDesktopDeviceId,
470
488
  smarterSelector: _smarterSelector,
471
489
  enabledModels: _enabledModels.toList(),
472
490
  defaultChatModel: _defaultChatModel,
@@ -631,36 +649,107 @@ class _SettingsPanelState extends State<SettingsPanel> {
631
649
  ],
632
650
  onChanged: (value) {
633
651
  if (value != null) {
634
- setState(() => _browserBackend = value);
652
+ setState(() {
653
+ _browserBackend = value;
654
+ _browserExtensionTokenId ??=
655
+ controller.selectedBrowserExtensionTokenId;
656
+ });
635
657
  }
636
658
  },
637
659
  ),
638
660
  const SizedBox(height: 10),
639
- Text(
640
- _browserBackend == 'extension'
641
- ? (controller.browserExtensionConnected
642
- ? 'Chrome extension connected.'
643
- : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
644
- : 'Cloud browser runtime is active.',
645
- style: TextStyle(color: _textSecondary, height: 1.4),
646
- ),
647
- const SizedBox(height: 10),
648
- Wrap(
649
- spacing: 10,
650
- runSpacing: 10,
651
- children: <Widget>[
652
- OutlinedButton.icon(
653
- onPressed: controller.downloadBrowserExtension,
654
- icon: Icon(Icons.download_outlined),
655
- label: Text('Download extension'),
656
- ),
657
- OutlinedButton.icon(
658
- onPressed: controller.refreshBrowserExtensionStatus,
659
- icon: Icon(Icons.sync),
660
- label: Text('Refresh status'),
661
+ if (_browserBackend == 'extension') ...<Widget>[
662
+ if (controller.browserExtensionTokens.isNotEmpty) ...<Widget>[
663
+ DropdownButtonFormField<String>(
664
+ initialValue: controller.browserExtensionTokens.any(
665
+ (token) => token['tokenId']?.toString() == _browserExtensionTokenId,
666
+ )
667
+ ? _browserExtensionTokenId
668
+ : null,
669
+ decoration: const InputDecoration(
670
+ labelText: 'Default extension',
671
+ helperText: 'Choose which paired Chrome extension controls browser actions.',
672
+ ),
673
+ items: controller.browserExtensionTokens.map((token) {
674
+ final tokenId = token['tokenId']?.toString() ?? '';
675
+ final label = token['name']?.toString().trim().isNotEmpty == true
676
+ ? token['name'].toString()
677
+ : tokenId;
678
+ final online = token['online'] == true || token['connected'] == true;
679
+ return DropdownMenuItem<String>(
680
+ value: tokenId,
681
+ child: Row(
682
+ children: <Widget>[
683
+ Icon(
684
+ online ? Icons.circle : Icons.circle_outlined,
685
+ size: 10,
686
+ color: online ? Colors.green : Colors.grey,
687
+ ),
688
+ const SizedBox(width: 8),
689
+ Flexible(
690
+ child: Text(
691
+ label,
692
+ maxLines: 1,
693
+ overflow: TextOverflow.ellipsis,
694
+ ),
695
+ ),
696
+ ],
697
+ ),
698
+ );
699
+ }).toList(),
700
+ onChanged: (value) {
701
+ if (value != null) {
702
+ setState(() => _browserExtensionTokenId = value);
703
+ }
704
+ },
661
705
  ),
706
+ const SizedBox(height: 10),
662
707
  ],
663
- ),
708
+ _buildInlineTestRow(
709
+ label: 'Chrome extension',
710
+ running: _extensionTestRunning,
711
+ result: _extensionTestResult,
712
+ note: controller.browserExtensionConnected
713
+ ? 'Connected — tap Test to verify the live link.'
714
+ : 'Not connected — download the extension, load it in Chrome, then pair after login.',
715
+ onTest: () async {
716
+ setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
717
+ try {
718
+ final r = await controller.testBrowserExtension();
719
+ if (mounted) {
720
+ setState(() => _extensionTestResult = r);
721
+ }
722
+ } catch (e) {
723
+ if (mounted) {
724
+ setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
725
+ }
726
+ } finally {
727
+ if (mounted) {
728
+ setState(() => _extensionTestRunning = false);
729
+ }
730
+ }
731
+ },
732
+ ),
733
+ const SizedBox(height: 10),
734
+ Wrap(
735
+ spacing: 10,
736
+ runSpacing: 10,
737
+ children: <Widget>[
738
+ OutlinedButton.icon(
739
+ onPressed: controller.downloadBrowserExtension,
740
+ icon: Icon(Icons.download_outlined),
741
+ label: Text('Download extension'),
742
+ ),
743
+ OutlinedButton.icon(
744
+ onPressed: controller.refreshBrowserExtensionStatus,
745
+ icon: Icon(Icons.sync),
746
+ label: Text('Refresh'),
747
+ ),
748
+ ],
749
+ ),
750
+ ] else ...<Widget>[
751
+ Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
752
+ ],
664
753
  const Divider(height: 32),
665
754
  Text(
666
755
  'CLI Runtime',
@@ -691,14 +780,73 @@ class _SettingsPanelState extends State<SettingsPanel> {
691
780
  }
692
781
  },
693
782
  ),
783
+ if (_cliBackend == 'desktop' && controller.desktopDevices.length > 1) ...<Widget>[
784
+ const SizedBox(height: 12),
785
+ DropdownButtonFormField<String>(
786
+ initialValue: controller.desktopDevices.any(
787
+ (d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
788
+ )
789
+ ? _cliDesktopDeviceId
790
+ : null,
791
+ decoration: const InputDecoration(
792
+ labelText: 'Desktop device',
793
+ helperText: 'Choose which desktop companion runs CLI commands.',
794
+ ),
795
+ items: controller.desktopDevices.map((device) {
796
+ final deviceId = device['deviceId']?.toString() ?? '';
797
+ final label = device['hostname']?.toString().isNotEmpty == true
798
+ ? device['hostname']!.toString()
799
+ : deviceId;
800
+ final online = device['online'] == true;
801
+ return DropdownMenuItem<String>(
802
+ value: deviceId,
803
+ child: Row(
804
+ children: <Widget>[
805
+ Icon(
806
+ online ? Icons.circle : Icons.circle_outlined,
807
+ size: 10,
808
+ color: online ? Colors.green : Colors.grey,
809
+ ),
810
+ const SizedBox(width: 8),
811
+ Text(label),
812
+ ],
813
+ ),
814
+ );
815
+ }).toList(),
816
+ onChanged: (value) {
817
+ if (value != null) {
818
+ setState(() => _cliDesktopDeviceId = value);
819
+ }
820
+ },
821
+ ),
822
+ ],
694
823
  const SizedBox(height: 10),
695
- Text(
696
- _cliBackend == 'desktop'
824
+ _buildInlineTestRow(
825
+ label: 'CLI',
826
+ running: _cliTestRunning,
827
+ result: _cliTestResult,
828
+ note: _cliBackend == 'desktop'
697
829
  ? (controller.desktopCompanionConnected
698
- ? 'Desktop app connected.'
699
- : 'Desktop app selected. Make sure the desktop companion is running and connected on your machine.')
700
- : 'Cloud CLI runtime is active.',
701
- style: TextStyle(color: _textSecondary, height: 1.4),
830
+ ? 'Desktop app connected — commands route locally through the companion.'
831
+ : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
832
+ : 'Cloud VM commands run in an isolated container.',
833
+ onTest: () async {
834
+ setState(() { _cliTestRunning = true; _cliTestResult = null; });
835
+ try {
836
+ final r = await controller.testCliRuntime();
837
+ if (mounted) {
838
+ setState(() => _cliTestResult = r);
839
+ }
840
+ } catch (e) {
841
+ if (mounted) {
842
+ setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
843
+ }
844
+ } finally {
845
+ if (mounted) {
846
+ setState(() => _cliTestRunning = false);
847
+ }
848
+ }
849
+ },
702
850
  ),
703
851
  const Divider(height: 32),
704
852
  Text(
@@ -1344,6 +1492,53 @@ class _SettingsPanelState extends State<SettingsPanel> {
1344
1492
  const SizedBox(height: 12),
1345
1493
  _InlineError(message: message),
1346
1494
  ],
1495
+ const SizedBox(height: 12),
1496
+ _buildInlineTestRow(
1497
+ label: 'Desktop companion',
1498
+ running: _desktopTestRunning,
1499
+ result: _desktopTestResult != null
1500
+ ? <String, dynamic>{
1501
+ 'passed': _desktopTestResult!['passed'] == true,
1502
+ 'detail': _desktopTestResult!['detail']?.toString() ?? '',
1503
+ }
1504
+ : null,
1505
+ note: controller.desktopCompanionConnected
1506
+ ? 'Connected — tap Test to fetch live device status from the server.'
1507
+ : 'Not connected. Make sure the desktop app is running on the target machine.',
1508
+ onTest: () async {
1509
+ setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
1510
+ try {
1511
+ final r = await controller.testDesktopCompanion();
1512
+ final active = r['activeDevice'];
1513
+ final multi = r['multipleOnline'] == true;
1514
+ String detail = r['detail']?.toString() ?? '';
1515
+ if (r['passed'] == true && active != null) {
1516
+ final label = active['label']?.toString() ?? 'Device';
1517
+ final plat = active['platform']?.toString() ?? '';
1518
+ final sc = active['permissions']?['screenCapture'] == true;
1519
+ final ic = active['permissions']?['inputControl'] == true;
1520
+ detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1521
+ ' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
1522
+ } else if (multi) {
1523
+ detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1524
+ }
1525
+ if (mounted) {
1526
+ setState(() => _desktopTestResult = <String, dynamic>{
1527
+ ...r,
1528
+ 'detail': detail,
1529
+ });
1530
+ }
1531
+ } catch (e) {
1532
+ if (mounted) {
1533
+ setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1534
+ }
1535
+ } finally {
1536
+ if (mounted) {
1537
+ setState(() => _desktopTestRunning = false);
1538
+ }
1539
+ }
1540
+ },
1541
+ ),
1347
1542
  const SizedBox(height: 14),
1348
1543
  Builder(
1349
1544
  builder: (context) {
@@ -1999,6 +2194,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
1999
2194
  )
2000
2195
  .toList();
2001
2196
  }
2197
+
2198
+ // Shared helper: small "Test" button + inline result row.
2199
+ Widget _buildInlineTestRow({
2200
+ required String label,
2201
+ required bool running,
2202
+ required Map<String, dynamic>? result,
2203
+ required VoidCallback onTest,
2204
+ String? note,
2205
+ }) {
2206
+ final passed = result?['passed'] == true;
2207
+ final detail = result?['detail']?.toString() ?? '';
2208
+ return Row(
2209
+ crossAxisAlignment: CrossAxisAlignment.start,
2210
+ children: <Widget>[
2211
+ Expanded(
2212
+ child: Column(
2213
+ crossAxisAlignment: CrossAxisAlignment.start,
2214
+ children: <Widget>[
2215
+ if (result != null)
2216
+ Row(
2217
+ children: <Widget>[
2218
+ Icon(
2219
+ passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
2220
+ size: 15,
2221
+ color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
2222
+ ),
2223
+ const SizedBox(width: 6),
2224
+ Expanded(
2225
+ child: Text(
2226
+ passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
2227
+ style: TextStyle(
2228
+ fontSize: 13,
2229
+ color: passed ? null : const Color(0xFFEF4444),
2230
+ ),
2231
+ ),
2232
+ ),
2233
+ ],
2234
+ )
2235
+ else if (note != null)
2236
+ Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
2237
+ ],
2238
+ ),
2239
+ ),
2240
+ const SizedBox(width: 12),
2241
+ SizedBox(
2242
+ width: 80,
2243
+ child: OutlinedButton(
2244
+ onPressed: running ? null : onTest,
2245
+ style: OutlinedButton.styleFrom(
2246
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
2247
+ textStyle: const TextStyle(fontSize: 12),
2248
+ ),
2249
+ child: running
2250
+ ? const SizedBox(
2251
+ width: 13,
2252
+ height: 13,
2253
+ child: CircularProgressIndicator(strokeWidth: 2),
2254
+ )
2255
+ : const Text('Test'),
2256
+ ),
2257
+ ),
2258
+ ],
2259
+ );
2260
+ }
2002
2261
  }
2003
2262
 
2004
2263
  class _AiProviderCard extends StatelessWidget {
@@ -476,6 +476,18 @@ class BackendClient {
476
476
  return getMap(baseUrl, '/api/runtime/config', allowUnauthorized: true);
477
477
  }
478
478
 
479
+ Future<Map<String, dynamic>> testCli(String baseUrl) async {
480
+ return getMap(baseUrl, '/api/system/test/cli');
481
+ }
482
+
483
+ Future<Map<String, dynamic>> testExtension(String baseUrl) async {
484
+ return getMap(baseUrl, '/api/system/test/extension');
485
+ }
486
+
487
+ Future<Map<String, dynamic>> testDesktop(String baseUrl) async {
488
+ return getMap(baseUrl, '/api/system/test/desktop');
489
+ }
490
+
479
491
  Future<Map<String, dynamic>> fetchBrowserStatus(String baseUrl) async {
480
492
  return getMap(baseUrl, '/api/browser/status');
481
493
  }
@@ -486,6 +498,15 @@ class BackendClient {
486
498
  return getMap(baseUrl, '/api/browser-extension/status');
487
499
  }
488
500
 
501
+ Future<Map<String, dynamic>> selectBrowserExtensionToken(
502
+ String baseUrl, {
503
+ required String tokenId,
504
+ }) async {
505
+ return postMap(baseUrl, '/api/browser-extension/select-token', <String, dynamic>{
506
+ 'tokenId': tokenId,
507
+ });
508
+ }
509
+
489
510
  Future<Map<String, dynamic>> launchBrowser(
490
511
  String baseUrl, {
491
512
  Map<String, dynamic>? payload,
@@ -534,6 +555,17 @@ class BackendClient {
534
555
  });
535
556
  }
536
557
 
558
+ Future<Map<String, dynamic>> hoverBrowserPoint(
559
+ String baseUrl, {
560
+ required int x,
561
+ required int y,
562
+ }) async {
563
+ return postMap(baseUrl, '/api/browser/mouse-move', <String, dynamic>{
564
+ 'x': x,
565
+ 'y': y,
566
+ });
567
+ }
568
+
537
569
  Future<Map<String, dynamic>> fillBrowser(
538
570
  String baseUrl, {
539
571
  required String selector,
@@ -603,6 +635,50 @@ class BackendClient {
603
635
  return _postEmpty(baseUrl, '/api/browser/close');
604
636
  }
605
637
 
638
+ Future<Map<String, dynamic>> startStream(
639
+ String baseUrl, {
640
+ required String platform,
641
+ required String deviceId,
642
+ int fps = 15,
643
+ int quality = 80,
644
+ String? displayId,
645
+ }) async {
646
+ return postMap(baseUrl, '/api/stream/start', <String, dynamic>{
647
+ 'platform': platform,
648
+ 'deviceId': deviceId,
649
+ 'fps': fps,
650
+ 'quality': quality,
651
+ if (displayId != null && displayId.isNotEmpty) 'displayId': displayId,
652
+ });
653
+ }
654
+
655
+ Future<Map<String, dynamic>> stopStream(
656
+ String baseUrl, {
657
+ required String platform,
658
+ required String deviceId,
659
+ }) async {
660
+ return postMap(baseUrl, '/api/stream/stop', <String, dynamic>{
661
+ 'platform': platform,
662
+ 'deviceId': deviceId,
663
+ });
664
+ }
665
+
666
+ Future<Map<String, dynamic>> fetchStreamStatus(
667
+ String baseUrl, {
668
+ String? platform,
669
+ String? deviceId,
670
+ }) async {
671
+ final query = <String>[];
672
+ if (platform != null && platform.isNotEmpty) {
673
+ query.add('platform=${Uri.encodeQueryComponent(platform)}');
674
+ }
675
+ if (deviceId != null && deviceId.isNotEmpty) {
676
+ query.add('deviceId=${Uri.encodeQueryComponent(deviceId)}');
677
+ }
678
+ final suffix = query.isEmpty ? '' : '?${query.join('&')}';
679
+ return getMap(baseUrl, '/api/stream/status$suffix');
680
+ }
681
+
606
682
  Future<Map<String, dynamic>> fetchAndroidStatus(String baseUrl) async {
607
683
  return getMap(baseUrl, '/api/android/status');
608
684
  }
@@ -663,6 +739,19 @@ class BackendClient {
663
739
  });
664
740
  }
665
741
 
742
+ Future<Map<String, dynamic>> hoverDesktop(
743
+ String baseUrl, {
744
+ String? deviceId,
745
+ required int x,
746
+ required int y,
747
+ }) async {
748
+ return postMap(baseUrl, '/api/desktop/mouse-move', <String, dynamic>{
749
+ if (deviceId != null && deviceId.isNotEmpty) 'deviceId': deviceId,
750
+ 'x': x,
751
+ 'y': y,
752
+ });
753
+ }
754
+
666
755
  Future<Map<String, dynamic>> dragDesktop(
667
756
  String baseUrl, {
668
757
  String? deviceId,