neoagent 3.0.1-beta.20 → 3.0.1-beta.22

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/README.md CHANGED
@@ -34,9 +34,10 @@
34
34
 
35
35
  ## 🚀 Install
36
36
 
37
- NeoAgent supports macOS and Linux hosts. You need Node.js 20 or newer; the
38
- installer handles the rest of the application setup and attempts to install
39
- QEMU for the isolated browser and terminal runtime.
37
+ NeoAgent supports macOS and Linux hosts. You need Node.js 20 or newer and
38
+ Docker for the isolated browser and terminal runtime; the installer handles the
39
+ rest of the application setup and pre-builds the guest runtime image when Docker
40
+ is available.
40
41
 
41
42
  ```bash
42
43
  npm install -g neoagent
@@ -9,18 +9,20 @@ remote access to work.
9
9
  - Node.js 20 or newer with npm
10
10
  - A macOS or Linux user account that can install packages
11
11
  - Enough disk and memory for the models and runtime you choose
12
- - QEMU for the isolated browser and terminal runtime
12
+ - Docker for the isolated browser and terminal runtime
13
13
 
14
- The installer attempts to install QEMU with Homebrew, `apt`, `dnf`, or `yum`.
15
- If the host uses another package manager, install QEMU first.
14
+ The isolated browser and CLI run each user's guest agent in a Docker container.
15
+ Install Docker first; the NeoAgent installer detects it and pre-builds the guest
16
+ runtime image (browser and dependencies baked in) so the first browser or CLI
17
+ action is instant. If Docker is missing, the rest of NeoAgent still installs and
18
+ the cloud browser/CLI become available once Docker is running.
16
19
 
17
20
  ```bash
18
- # macOS
19
- brew install qemu
21
+ # macOS — Docker Desktop
22
+ # https://www.docker.com/products/docker-desktop/
20
23
 
21
- # Ubuntu or Debian
22
- sudo apt-get update
23
- sudo apt-get install -y qemu-system qemu-utils
24
+ # Ubuntu or Debian — Docker Engine
25
+ # https://docs.docker.com/engine/install/
24
26
  ```
25
27
 
26
28
  ## Install NeoAgent
@@ -66,7 +66,7 @@ Protect backups as credentials and personal data.
66
66
  |---|---|
67
67
  | Service does not start | `neoagent status`, then `neoagent logs` |
68
68
  | Chat has no response | Provider connection, selected model, provider quota |
69
- | Browser or shell unavailable | QEMU installation and first runtime boot |
69
+ | Browser or shell unavailable | Docker installed and running, then first runtime boot |
70
70
  | OAuth fails | Reachable HTTPS `PUBLIC_URL` and exact callback URI |
71
71
  | Messaging receives nothing | Channel credentials, webhook, and allowlists |
72
72
  | Task does not run | Enabled state, trigger account, next run, **Runs** |
@@ -161,6 +161,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
161
161
  String? _emailInlineError;
162
162
  String? _passwordSuccessMessage;
163
163
  String? _passwordInlineError;
164
+ bool _isExportingData = false;
165
+ bool _isDeletingAccount = false;
164
166
 
165
167
  @override
166
168
  void initState() {
@@ -592,10 +594,146 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
592
594
  .toList(),
593
595
  ),
594
596
  ],
597
+ const SizedBox(height: 28),
598
+ const _SectionTitle('Your data'),
599
+ const SizedBox(height: 10),
600
+ Text(
601
+ 'Download a copy of your data, or permanently delete your account. '
602
+ 'Deletion removes all your conversations, memories, files, tasks and '
603
+ 'settings and cannot be undone.',
604
+ style: TextStyle(color: _textSecondary, height: 1.45),
605
+ ),
606
+ const SizedBox(height: 14),
607
+ Wrap(
608
+ spacing: 12,
609
+ runSpacing: 12,
610
+ children: <Widget>[
611
+ OutlinedButton.icon(
612
+ onPressed: _isExportingData ? null : _exportMyData,
613
+ icon: _isExportingData
614
+ ? const SizedBox.square(
615
+ dimension: 16,
616
+ child: CircularProgressIndicator(strokeWidth: 2),
617
+ )
618
+ : const Icon(Icons.download_outlined),
619
+ label: const Text('Export my data'),
620
+ ),
621
+ OutlinedButton.icon(
622
+ onPressed: _isDeletingAccount ? null : _confirmDeleteAccount,
623
+ style: OutlinedButton.styleFrom(
624
+ foregroundColor: _danger,
625
+ side: BorderSide(color: _danger.withValues(alpha: 0.6)),
626
+ ),
627
+ icon: _isDeletingAccount
628
+ ? const SizedBox.square(
629
+ dimension: 16,
630
+ child: CircularProgressIndicator(strokeWidth: 2),
631
+ )
632
+ : const Icon(Icons.delete_forever_outlined),
633
+ label: const Text('Delete account'),
634
+ ),
635
+ ],
636
+ ),
595
637
  ],
596
638
  );
597
639
  }
598
640
 
641
+ Future<void> _exportMyData() async {
642
+ setState(() => _isExportingData = true);
643
+ try {
644
+ final export = await widget.controller.backendClient.exportMyData(
645
+ widget.controller.backendUrl,
646
+ );
647
+ final pretty = const JsonEncoder.withIndent(' ').convert(export);
648
+ await Clipboard.setData(ClipboardData(text: pretty));
649
+ if (!mounted) return;
650
+ ScaffoldMessenger.of(context).showSnackBar(
651
+ const SnackBar(
652
+ content: Text('Your data export was copied to the clipboard.'),
653
+ ),
654
+ );
655
+ } catch (err) {
656
+ if (!mounted) return;
657
+ ScaffoldMessenger.of(context).showSnackBar(
658
+ SnackBar(content: Text('Could not export your data: $err')),
659
+ );
660
+ } finally {
661
+ if (mounted) setState(() => _isExportingData = false);
662
+ }
663
+ }
664
+
665
+ Future<void> _confirmDeleteAccount() async {
666
+ final username = widget.controller.user?['username']?.toString() ?? '';
667
+ final confirmController = TextEditingController();
668
+ final confirmed = await showDialog<bool>(
669
+ context: context,
670
+ builder: (dialogContext) {
671
+ return StatefulBuilder(
672
+ builder: (context, setDialogState) {
673
+ final matches = confirmController.text.trim() == username;
674
+ return AlertDialog(
675
+ title: const Text('Delete account permanently?'),
676
+ content: Column(
677
+ mainAxisSize: MainAxisSize.min,
678
+ crossAxisAlignment: CrossAxisAlignment.start,
679
+ children: <Widget>[
680
+ const Text(
681
+ 'This permanently erases all of your data and cannot be '
682
+ 'undone. Type your username to confirm.',
683
+ ),
684
+ const SizedBox(height: 14),
685
+ TextField(
686
+ controller: confirmController,
687
+ autofocus: true,
688
+ onChanged: (_) => setDialogState(() {}),
689
+ decoration: InputDecoration(
690
+ labelText: 'Username',
691
+ hintText: username,
692
+ ),
693
+ ),
694
+ ],
695
+ ),
696
+ actions: <Widget>[
697
+ TextButton(
698
+ onPressed: () => Navigator.of(dialogContext).pop(false),
699
+ child: const Text('Cancel'),
700
+ ),
701
+ FilledButton(
702
+ style: FilledButton.styleFrom(backgroundColor: _danger),
703
+ onPressed: matches
704
+ ? () => Navigator.of(dialogContext).pop(true)
705
+ : null,
706
+ child: const Text('Delete forever'),
707
+ ),
708
+ ],
709
+ );
710
+ },
711
+ );
712
+ },
713
+ );
714
+ confirmController.dispose();
715
+ if (confirmed != true) return;
716
+
717
+ setState(() => _isDeletingAccount = true);
718
+ try {
719
+ await widget.controller.backendClient.deleteMyAccount(
720
+ baseUrl: widget.controller.backendUrl,
721
+ confirmUsername: username,
722
+ );
723
+ if (!mounted) return;
724
+ ScaffoldMessenger.of(context).showSnackBar(
725
+ const SnackBar(content: Text('Your account has been deleted.')),
726
+ );
727
+ await widget.controller.logout();
728
+ } catch (err) {
729
+ if (!mounted) return;
730
+ setState(() => _isDeletingAccount = false);
731
+ ScaffoldMessenger.of(context).showSnackBar(
732
+ SnackBar(content: Text('Could not delete your account: $err')),
733
+ );
734
+ }
735
+ }
736
+
599
737
  String _formatTokens(int amount) {
600
738
  if (amount >= 1000000) {
601
739
  final value = amount / 1000000;
@@ -429,6 +429,45 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
429
429
  if (!force && !_stickToBottom) return;
430
430
  final generation = ++_scrollGeneration;
431
431
 
432
+ if (_awaitingInitialScrollSettle) {
433
+ // Frame-by-frame stability detection: keep jumping and checking until
434
+ // maxScrollExtent is the same two frames in a row, then reveal.
435
+ // Falls back to revealing after 120 frames (~2 s) to avoid infinite wait.
436
+ double? prevExtent;
437
+ void settleInitial(int framesLeft) {
438
+ WidgetsBinding.instance.addPostFrameCallback((_) {
439
+ if (!mounted || generation != _scrollGeneration || !_scrollController.hasClients) {
440
+ return;
441
+ }
442
+ final pos = _scrollController.position;
443
+ double? currentExtent;
444
+ if (pos.hasContentDimensions) {
445
+ currentExtent = pos.maxScrollExtent;
446
+ _ignoreScrollUpdates = true;
447
+ _scrollController.jumpTo(currentExtent);
448
+ _ignoreScrollUpdates = false;
449
+ }
450
+ _stickToBottom = true;
451
+ final stable = currentExtent != null && currentExtent == prevExtent;
452
+ prevExtent = currentExtent;
453
+ if (stable || framesLeft <= 0) {
454
+ if (_awaitingInitialScrollSettle) {
455
+ setState(() {
456
+ _awaitingInitialScrollSettle = false;
457
+ _visibleMessageCountAtLastSettle =
458
+ widget.controller.visibleChatMessages.length;
459
+ });
460
+ }
461
+ return;
462
+ }
463
+ settleInitial(framesLeft - 1);
464
+ });
465
+ }
466
+ settleInitial(120);
467
+ return;
468
+ }
469
+
470
+ // Regular settle (content already visible): fixed passes with short delay.
432
471
  void settle(int remainingPasses) {
433
472
  WidgetsBinding.instance.addPostFrameCallback((_) {
434
473
  if (!mounted ||
@@ -452,12 +491,6 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
452
491
  settle(remainingPasses - 1);
453
492
  }
454
493
  });
455
- } else if (_awaitingInitialScrollSettle) {
456
- setState(() {
457
- _awaitingInitialScrollSettle = false;
458
- _visibleMessageCountAtLastSettle =
459
- widget.controller.visibleChatMessages.length;
460
- });
461
494
  }
462
495
  });
463
496
  }
@@ -228,6 +228,23 @@ class BackendClient {
228
228
  return getMap(baseUrl, '/api/account/usage');
229
229
  }
230
230
 
231
+ /// GDPR data portability — returns a structured export of the signed-in
232
+ /// user's own data (credential fields are redacted server-side).
233
+ Future<Map<String, dynamic>> exportMyData(String baseUrl) async {
234
+ return getMap(baseUrl, '/api/account/export');
235
+ }
236
+
237
+ /// GDPR right to erasure — permanently deletes the signed-in user's account
238
+ /// and all associated data. [confirmUsername] must equal the account username.
239
+ Future<Map<String, dynamic>> deleteMyAccount({
240
+ required String baseUrl,
241
+ required String confirmUsername,
242
+ }) async {
243
+ return postMap(baseUrl, '/api/account/delete', <String, dynamic>{
244
+ 'confirmUsername': confirmUsername,
245
+ });
246
+ }
247
+
231
248
  Future<Map<String, dynamic>> updateAccountEmail({
232
249
  required String baseUrl,
233
250
  required String email,
@@ -848,7 +848,7 @@ p { margin: 0; text-wrap: pretty; }
848
848
  .logo .ri { transform-box: fill-box; transform-origin: center; }
849
849
  .logo .ri-a { animation: spinA 18s linear infinite; }
850
850
  .logo .ri-b { animation: spinB 26s linear infinite; }
851
- .logo .nd { animation: nd 14s linear infinite; transform-box: fill-box; transform-origin: 50px 50px; }
851
+ .logo .nd { animation: nd 14s linear infinite; transform-box: view-box; transform-origin: 50px 50px; }
852
852
  @keyframes spinA { to { transform: rotate(360deg); } }
853
853
  @keyframes spinB { to { transform: rotate(-360deg); } }
854
854
  @keyframes nd { to { transform: rotate(360deg); } }
package/lib/manager.js CHANGED
@@ -1432,81 +1432,43 @@ function startFallback() {
1432
1432
  logOk(`Started detached process (pid ${child.pid})`);
1433
1433
  }
1434
1434
 
1435
- async function ensureQemuInstalled({ required = false } = {}) {
1436
- heading('Ensure QEMU Installed');
1435
+ // The isolated browser/CLI runtime runs each user's guest agent in a Docker
1436
+ // container built from a baked image (browser + dependencies installed at build
1437
+ // time). Docker cannot be auto-installed safely across platforms, so when it is
1438
+ // missing we surface clear guidance; when it is present we pre-build the guest
1439
+ // image so the first browser/CLI action is instant rather than paying a one-time
1440
+ // multi-minute build on demand.
1441
+ async function ensureContainerRuntime({ required = false } = {}) {
1442
+ heading('Ensure Container Runtime');
1437
1443
  const platform = detectPlatform();
1438
-
1439
- const hasSystem = commandExists('qemu-system-x86_64') || commandExists('qemu-system-aarch64');
1440
- const hasImg = commandExists('qemu-img');
1441
1444
 
1442
- if (hasSystem && hasImg) {
1443
- logOk('QEMU components are already installed');
1444
- return;
1445
- }
1445
+ const dockerReady = commandExists('docker')
1446
+ && spawnSync('docker', ['info'], { stdio: 'ignore', timeout: 10000 }).status === 0;
1446
1447
 
1447
- logInfo('QEMU components not found. Attempting to install...');
1448
-
1449
- try {
1450
- if (platform === 'macos') {
1451
- if (!commandExists('brew')) {
1452
- const message = 'Homebrew is required for automatic QEMU install on macOS. Install Homebrew from https://brew.sh/ and run `neoagent install` again.';
1453
- if (required) throw new Error(message);
1454
- logWarn('Homebrew not found; VM features will stay unavailable until QEMU is installed.');
1455
- rememberInstallAction(message);
1456
- return false;
1457
- }
1458
- logInfo('Running "brew install qemu"...');
1459
- runOrThrow('brew', ['install', 'qemu']);
1460
- } else if (platform === 'linux') {
1461
- const isArm = process.arch === 'arm64' || process.arch === 'aarch64';
1462
- if (commandExists('apt-get')) {
1463
- const pkgs = ['qemu-utils'];
1464
- if (isArm) {
1465
- pkgs.push('qemu-system-arm');
1466
- } else {
1467
- pkgs.push('qemu-system-x86');
1468
- }
1469
- logInfo(`Running "sudo apt-get update && sudo apt-get install -y ${pkgs.join(' ')}"...`);
1470
- runOrThrow('sudo', ['apt-get', 'update']);
1471
- runOrThrow('sudo', ['apt-get', 'install', '-y', ...pkgs]);
1472
- } else if (commandExists('dnf')) {
1473
- logInfo('Running "sudo dnf install -y qemu-kvm qemu-img"...');
1474
- runOrThrow('sudo', ['dnf', 'install', '-y', 'qemu-kvm', 'qemu-img']);
1475
- } else if (commandExists('yum')) {
1476
- logInfo('Running "sudo yum install -y qemu-kvm qemu-img"...');
1477
- runOrThrow('sudo', ['yum', 'install', '-y', 'qemu-kvm', 'qemu-img']);
1478
- } else {
1479
- const message = 'Unsupported Linux package manager for automatic QEMU install. Install qemu-system and qemu-utils, then run `neoagent install` again.';
1480
- if (required) throw new Error(message);
1481
- logWarn('Could not find apt-get, dnf, or yum for QEMU installation.');
1482
- rememberInstallAction(message);
1483
- return false;
1484
- }
1485
- } else {
1486
- const message = 'Unsupported platform for automatic QEMU installation. Install QEMU manually if you need VM-isolated runtime features.';
1487
- if (required) throw new Error(message);
1488
- logWarn('Skipping QEMU auto-install on this platform.');
1489
- rememberInstallAction(message);
1490
- return false;
1491
- }
1492
- } catch (err) {
1493
- if (required) throw err;
1494
- logWarn(`QEMU install did not complete: ${err.message}`);
1495
- rememberInstallAction('Install QEMU manually or rerun `neoagent install` after your package manager is ready.');
1448
+ if (!dockerReady) {
1449
+ const installHint = platform === 'macos'
1450
+ ? 'Install Docker Desktop from https://www.docker.com/products/docker-desktop/ and start it.'
1451
+ : 'Install Docker Engine (https://docs.docker.com/engine/install/) and ensure the daemon is running.';
1452
+ const message = `Docker is required for the isolated browser/CLI runtime. ${installHint} Then run \`neoagent install\` again.`;
1453
+ if (required) throw new Error(message);
1454
+ logWarn('Docker not detected; the cloud browser and CLI will be unavailable until Docker is installed and running.');
1455
+ rememberInstallAction(message);
1496
1456
  return false;
1497
1457
  }
1498
1458
 
1499
- const verifiedSystem = commandExists('qemu-system-x86_64') || commandExists('qemu-system-aarch64');
1500
- const verifiedImg = commandExists('qemu-img');
1459
+ logOk('Docker is installed and the daemon is running');
1501
1460
 
1502
- if (verifiedSystem && verifiedImg) {
1503
- logOk('QEMU installed successfully');
1461
+ try {
1462
+ logInfo('Building the guest runtime image (one-time; downloads the browser and dependencies)…');
1463
+ const { GuestImageBuilder } = require('../server/services/runtime/guest_image');
1464
+ await new GuestImageBuilder({ runtimeProfile: 'browser_cli' }).ensure();
1465
+ logOk('Guest runtime image is ready');
1504
1466
  return true;
1505
- } else {
1506
- const message = 'QEMU installation finished but required binaries were not found in PATH.';
1467
+ } catch (err) {
1468
+ const message = `Guest runtime image build did not complete: ${err.message}`;
1507
1469
  if (required) throw new Error(message);
1508
1470
  logWarn(message);
1509
- rememberInstallAction('Ensure qemu-system and qemu-img are available in PATH, then run `neoagent install` again.');
1471
+ rememberInstallAction('Rerun `neoagent install` to retry building the guest runtime image, or it will build automatically on first use.');
1510
1472
  return false;
1511
1473
  }
1512
1474
  }
@@ -1585,7 +1547,7 @@ async function cmdInstall() {
1585
1547
  }
1586
1548
 
1587
1549
  installDependencies();
1588
- await ensureQemuInstalled({ required: false });
1550
+ await ensureContainerRuntime({ required: false });
1589
1551
  ensureYtDlpInstalled();
1590
1552
  buildBundledWebClientIfPossible({ required: true });
1591
1553
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.1-beta.20",
3
+ "version": "3.0.1-beta.22",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -1 +1 @@
1
- 83126cc646128e385732d20834eb9ab3
1
+ 7f39a61f6aba2158c5e1102d384e066f
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"77e2e94772b6eb43759e34ed1ad7da4674e19c
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "1135898170" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "549021239" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });