neoagent 2.4.1-beta.7 → 2.4.1-beta.8

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.
@@ -615,6 +615,50 @@ class BackendClient {
615
615
  return _postEmpty(baseUrl, '/api/browser/close');
616
616
  }
617
617
 
618
+ Future<Map<String, dynamic>> startStream(
619
+ String baseUrl, {
620
+ required String platform,
621
+ required String deviceId,
622
+ int fps = 15,
623
+ int quality = 80,
624
+ String? displayId,
625
+ }) async {
626
+ return postMap(baseUrl, '/api/stream/start', <String, dynamic>{
627
+ 'platform': platform,
628
+ 'deviceId': deviceId,
629
+ 'fps': fps,
630
+ 'quality': quality,
631
+ if (displayId != null && displayId.isNotEmpty) 'displayId': displayId,
632
+ });
633
+ }
634
+
635
+ Future<Map<String, dynamic>> stopStream(
636
+ String baseUrl, {
637
+ required String platform,
638
+ required String deviceId,
639
+ }) async {
640
+ return postMap(baseUrl, '/api/stream/stop', <String, dynamic>{
641
+ 'platform': platform,
642
+ 'deviceId': deviceId,
643
+ });
644
+ }
645
+
646
+ Future<Map<String, dynamic>> fetchStreamStatus(
647
+ String baseUrl, {
648
+ String? platform,
649
+ String? deviceId,
650
+ }) async {
651
+ final query = <String>[];
652
+ if (platform != null && platform.isNotEmpty) {
653
+ query.add('platform=${Uri.encodeQueryComponent(platform)}');
654
+ }
655
+ if (deviceId != null && deviceId.isNotEmpty) {
656
+ query.add('deviceId=${Uri.encodeQueryComponent(deviceId)}');
657
+ }
658
+ final suffix = query.isEmpty ? '' : '?${query.join('&')}';
659
+ return getMap(baseUrl, '/api/stream/status$suffix');
660
+ }
661
+
618
662
  Future<Map<String, dynamic>> fetchAndroidStatus(String baseUrl) async {
619
663
  return getMap(baseUrl, '/api/android/status');
620
664
  }
@@ -177,6 +177,18 @@ class DesktopCompanionActions {
177
177
  };
178
178
  }
179
179
 
180
+ Future<Uint8List> compressToJpeg(
181
+ DesktopCompanionSnapshot snapshot,
182
+ int quality,
183
+ ) async {
184
+ final raw = _decodeScreenshotBytes(snapshot.screenshotBase64);
185
+ if (_looksLikeJpeg(raw)) return raw;
186
+ final decoded = img.decodeImage(raw);
187
+ if (decoded == null) return raw;
188
+ final normalizedQuality = quality.clamp(30, 95);
189
+ return Uint8List.fromList(img.encodeJpg(decoded, quality: normalizedQuality));
190
+ }
191
+
180
192
  Future<Map<String, Object?>> observe({
181
193
  bool includeTree = false,
182
194
  String? activeDisplayId,
@@ -450,15 +462,15 @@ class DesktopCompanionActions {
450
462
  await stdoutSub.cancel();
451
463
  await stderrSub.cancel();
452
464
 
453
- String _trim(StringBuffer buf) {
465
+ String trimOutput(StringBuffer buf) {
454
466
  final s = buf.toString().trim();
455
467
  return s.length > maxChars ? '${s.substring(0, maxChars)}\n...[truncated, ${s.length} total chars]' : s;
456
468
  }
457
469
 
458
470
  return <String, Object?>{
459
471
  'exitCode': exitCode,
460
- 'stdout': _trim(stdoutBuf),
461
- 'stderr': _trim(stderrBuf),
472
+ 'stdout': trimOutput(stdoutBuf),
473
+ 'stderr': trimOutput(stderrBuf),
462
474
  'timedOut': timedOut,
463
475
  'killed': timedOut,
464
476
  'durationMs': DateTime.now().difference(startedAt).inMilliseconds,
@@ -588,6 +600,23 @@ class DesktopCompanionActions {
588
600
  }
589
601
  }
590
602
 
603
+ Uint8List _decodeScreenshotBytes(String screenshotBase64) {
604
+ final trimmed = screenshotBase64.trim();
605
+ final commaIndex = trimmed.indexOf(',');
606
+ final encoded = trimmed.startsWith('data:image/') && commaIndex >= 0
607
+ ? trimmed.substring(commaIndex + 1)
608
+ : trimmed;
609
+ return Uint8List.fromList(base64Decode(encoded));
610
+ }
611
+
612
+ bool _looksLikeJpeg(Uint8List bytes) {
613
+ return bytes.length >= 4 &&
614
+ bytes[0] == 0xff &&
615
+ bytes[1] == 0xd8 &&
616
+ bytes[bytes.length - 2] == 0xff &&
617
+ bytes[bytes.length - 1] == 0xd9;
618
+ }
619
+
591
620
  String _normalizeMouseButton(String button) {
592
621
  final value = button.trim().toLowerCase();
593
622
  if (value == 'left' || value == 'right' || value == 'middle') {
@@ -25,6 +25,10 @@ class DesktopCompanionManager extends ChangeNotifier {
25
25
  final DesktopCompanionActions _actions;
26
26
  WebSocket? _socket;
27
27
  Timer? _reconnectTimer;
28
+ Timer? _streamTimer;
29
+ bool _streamCaptureInFlight = false;
30
+ int _frameSeq = 0;
31
+ int _streamGeneration = 0;
28
32
 
29
33
  String _backendUrl = '';
30
34
  String _sessionCookie = '';
@@ -126,6 +130,7 @@ class DesktopCompanionManager extends ChangeNotifier {
126
130
  Future<void> disconnect() async {
127
131
  _reconnectTimer?.cancel();
128
132
  _reconnectTimer = null;
133
+ _stopStreaming();
129
134
  _connecting = false;
130
135
  _connected = false;
131
136
  final socket = _socket;
@@ -327,6 +332,10 @@ class DesktopCompanionManager extends ChangeNotifier {
327
332
  );
328
333
  case 'captureFrame':
329
334
  return _actions.captureFrame(activeDisplayId: _activeDisplayId);
335
+ case 'startStream':
336
+ return _startStreaming(payload);
337
+ case 'stopStream':
338
+ return _stopStreaming();
330
339
  case 'observe':
331
340
  return _actions.observe(
332
341
  includeTree: payload['includeTree'] == true,
@@ -404,6 +413,7 @@ class DesktopCompanionManager extends ChangeNotifier {
404
413
  }
405
414
 
406
415
  void _handleSocketClosed() {
416
+ _stopStreaming();
407
417
  _socket = null;
408
418
  _connecting = false;
409
419
  _connected = false;
@@ -415,6 +425,7 @@ class DesktopCompanionManager extends ChangeNotifier {
415
425
  void dispose() {
416
426
  _reconnectTimer?.cancel();
417
427
  _reconnectTimer = null;
428
+ _stopStreaming();
418
429
  _connecting = false;
419
430
  _connected = false;
420
431
  _enabled = false;
@@ -448,6 +459,78 @@ class DesktopCompanionManager extends ChangeNotifier {
448
459
  );
449
460
  }
450
461
 
462
+ Future<Map<String, Object?>> _startStreaming(
463
+ Map<String, Object?> payload,
464
+ ) async {
465
+ _streamTimer?.cancel();
466
+ final generation = ++_streamGeneration;
467
+ final fps = ((payload['fps'] as num?)?.round() ?? 15).clamp(1, 20);
468
+ final quality = ((payload['quality'] as num?)?.round() ?? 80).clamp(30, 95);
469
+ final displayId = payload['displayId']?.toString().trim();
470
+ if (displayId != null && displayId.isNotEmpty) {
471
+ _activeDisplayId = displayId;
472
+ }
473
+ final interval = Duration(milliseconds: max(1, (1000 / fps).floor()));
474
+ _frameSeq = 0;
475
+ _streamTimer = Timer.periodic(interval, (_) {
476
+ unawaited(_captureAndSendBinaryFrame(quality, generation));
477
+ });
478
+ unawaited(_captureAndSendBinaryFrame(quality, generation));
479
+ return <String, Object?>{
480
+ 'success': true,
481
+ 'fps': fps,
482
+ 'quality': quality,
483
+ 'displayId': _activeDisplayId,
484
+ };
485
+ }
486
+
487
+ Map<String, Object?> _stopStreaming() {
488
+ _streamTimer?.cancel();
489
+ _streamTimer = null;
490
+ _streamGeneration++;
491
+ _streamCaptureInFlight = false;
492
+ return <String, Object?>{'success': true};
493
+ }
494
+
495
+ Future<void> _captureAndSendBinaryFrame(int quality, int generation) async {
496
+ final socket = _socket;
497
+ if (socket == null ||
498
+ !_connected ||
499
+ _streamCaptureInFlight ||
500
+ generation != _streamGeneration) {
501
+ return;
502
+ }
503
+ _streamCaptureInFlight = true;
504
+ try {
505
+ final snapshot = await _actions.captureSnapshot(
506
+ activeDisplayId: _activeDisplayId,
507
+ );
508
+ if (snapshot == null) return;
509
+ final jpeg = await _actions.compressToJpeg(snapshot, quality);
510
+ if (jpeg.isEmpty) return;
511
+ if (!_connected || generation != _streamGeneration || _socket != socket) {
512
+ return;
513
+ }
514
+ final frame = Uint8List(10 + jpeg.length);
515
+ final header = ByteData.sublistView(frame, 0, 10);
516
+ header.setUint8(0, 0x01);
517
+ header.setUint32(1, _frameSeq++ & 0xffffffff, Endian.big);
518
+ header.setUint32(
519
+ 5,
520
+ DateTime.now().millisecondsSinceEpoch & 0xffffffff,
521
+ Endian.big,
522
+ );
523
+ header.setUint8(9, 0x01);
524
+ frame.setRange(10, frame.length, jpeg);
525
+ socket.add(frame);
526
+ } catch (error) {
527
+ _errorMessage = 'Desktop stream capture failed: $error';
528
+ notifyListeners();
529
+ } finally {
530
+ _streamCaptureInFlight = false;
531
+ }
532
+ }
533
+
451
534
  Future<void> _openMacPermissionSettings(String key) async {
452
535
  final uri = switch (key) {
453
536
  'screencapture' =>
@@ -0,0 +1,155 @@
1
+ import 'dart:typed_data';
2
+
3
+ import 'package:flutter/material.dart';
4
+ import 'package:socket_io_client/socket_io_client.dart' as io;
5
+
6
+ class StreamRenderer extends StatefulWidget {
7
+ const StreamRenderer({
8
+ super.key,
9
+ required this.socket,
10
+ required this.deviceId,
11
+ required this.platform,
12
+ required this.remoteResolution,
13
+ this.onTap,
14
+ this.onType,
15
+ this.fit = BoxFit.contain,
16
+ });
17
+
18
+ final io.Socket socket;
19
+ final String deviceId;
20
+ final String platform;
21
+ final Size remoteResolution;
22
+ final void Function(double x, double y)? onTap;
23
+ final void Function(String text)? onType;
24
+ final BoxFit fit;
25
+
26
+ @override
27
+ State<StreamRenderer> createState() => _StreamRendererState();
28
+ }
29
+
30
+ class _StreamRendererState extends State<StreamRenderer> {
31
+ Uint8List? _frame;
32
+
33
+ @override
34
+ void initState() {
35
+ super.initState();
36
+ widget.socket.on('stream:frame', _onFrame);
37
+ widget.socket.emit('stream:subscribe', <String, Object?>{
38
+ 'deviceId': widget.deviceId,
39
+ 'platform': widget.platform,
40
+ });
41
+ }
42
+
43
+ @override
44
+ void didUpdateWidget(StreamRenderer oldWidget) {
45
+ super.didUpdateWidget(oldWidget);
46
+ if (oldWidget.socket == widget.socket &&
47
+ oldWidget.deviceId == widget.deviceId &&
48
+ oldWidget.platform == widget.platform) {
49
+ return;
50
+ }
51
+ oldWidget.socket.off('stream:frame', _onFrame);
52
+ oldWidget.socket.emit('stream:unsubscribe', <String, Object?>{
53
+ 'deviceId': oldWidget.deviceId,
54
+ 'platform': oldWidget.platform,
55
+ });
56
+ widget.socket.on('stream:frame', _onFrame);
57
+ widget.socket.emit('stream:subscribe', <String, Object?>{
58
+ 'deviceId': widget.deviceId,
59
+ 'platform': widget.platform,
60
+ });
61
+ }
62
+
63
+ void _onFrame(dynamic data) {
64
+ Object? meta;
65
+ Object? bytes;
66
+ if (data is List && data.length >= 2) {
67
+ meta = data[0];
68
+ bytes = data[1];
69
+ }
70
+ if (meta is Map) {
71
+ final frameDeviceId = meta['deviceId']?.toString() ?? '';
72
+ final framePlatform = meta['platform']?.toString() ?? '';
73
+ if (frameDeviceId.isNotEmpty && frameDeviceId != widget.deviceId) {
74
+ return;
75
+ }
76
+ if (framePlatform.isNotEmpty && framePlatform != widget.platform) {
77
+ return;
78
+ }
79
+ }
80
+ final frame = switch (bytes) {
81
+ Uint8List value => value,
82
+ List<int> value => Uint8List.fromList(value),
83
+ ByteBuffer value => Uint8List.view(value),
84
+ _ => null,
85
+ };
86
+ if (frame == null || frame.isEmpty || !mounted) return;
87
+ setState(() => _frame = frame);
88
+ }
89
+
90
+ @override
91
+ Widget build(BuildContext context) {
92
+ final frame = _frame;
93
+ if (frame == null) {
94
+ return const Center(child: CircularProgressIndicator());
95
+ }
96
+ return LayoutBuilder(
97
+ builder: (context, constraints) {
98
+ return GestureDetector(
99
+ behavior: HitTestBehavior.opaque,
100
+ onTapDown: widget.onTap == null
101
+ ? null
102
+ : (details) => _handleTap(details, constraints.biggest),
103
+ child: Image.memory(
104
+ frame,
105
+ gaplessPlayback: true,
106
+ fit: widget.fit,
107
+ width: constraints.maxWidth,
108
+ height: constraints.maxHeight,
109
+ ),
110
+ );
111
+ },
112
+ );
113
+ }
114
+
115
+ void _handleTap(TapDownDetails details, Size boxSize) {
116
+ final remote = widget.remoteResolution;
117
+ if (remote.width <= 0 || remote.height <= 0 || boxSize.width <= 0 || boxSize.height <= 0) {
118
+ return;
119
+ }
120
+ final imageAspect = remote.width / remote.height;
121
+ final boxAspect = boxSize.width / boxSize.height;
122
+ double renderWidth;
123
+ double renderHeight;
124
+ double offsetX = 0;
125
+ double offsetY = 0;
126
+ if (boxAspect > imageAspect) {
127
+ renderHeight = boxSize.height;
128
+ renderWidth = renderHeight * imageAspect;
129
+ offsetX = (boxSize.width - renderWidth) / 2;
130
+ } else {
131
+ renderWidth = boxSize.width;
132
+ renderHeight = renderWidth / imageAspect;
133
+ offsetY = (boxSize.height - renderHeight) / 2;
134
+ }
135
+ final localX = details.localPosition.dx - offsetX;
136
+ final localY = details.localPosition.dy - offsetY;
137
+ if (localX < 0 || localY < 0 || localX > renderWidth || localY > renderHeight) {
138
+ return;
139
+ }
140
+ widget.onTap?.call(
141
+ localX * remote.width / renderWidth,
142
+ localY * remote.height / renderHeight,
143
+ );
144
+ }
145
+
146
+ @override
147
+ void dispose() {
148
+ widget.socket.emit('stream:unsubscribe', <String, Object?>{
149
+ 'deviceId': widget.deviceId,
150
+ 'platform': widget.platform,
151
+ });
152
+ widget.socket.off('stream:frame', _onFrame);
153
+ super.dispose();
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.1-beta.7",
3
+ "version": "2.4.1-beta.8",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -195,6 +195,13 @@ function requireCapability(controller, name) {
195
195
  app.post('/browser/launch', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').launch(req.body || {})));
196
196
  app.post('/browser/navigate', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').navigate(req.body?.url, req.body || {})));
197
197
  app.post('/browser/screenshot', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').screenshot(req.body || {})));
198
+ app.post('/browser/screenshot-jpeg', async (req, res) => handle(res, async () => {
199
+ const jpeg = await requireCapability(browserController, 'browser').screenshotJpeg(req.body?.quality, req.body || {});
200
+ return {
201
+ contentType: 'image/jpeg',
202
+ contentBase64: Buffer.from(jpeg).toString('base64'),
203
+ };
204
+ }));
198
205
  app.post('/browser/click', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').click(req.body?.selector, req.body?.text, req.body?.screenshot !== false)));
199
206
  app.post('/browser/click-point', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').clickPoint(req.body?.x, req.body?.y, req.body?.screenshot !== false)));
200
207
  app.post('/browser/fill', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').type(req.body?.selector, String(req.body?.value ?? req.body?.text ?? ''), req.body || {})));
@@ -25,6 +25,7 @@ const routeRegistry = [
25
25
  { basePath: '/api/browser-extension', modulePath: '../routes/browser_extension' },
26
26
  { basePath: '/api/android', modulePath: '../routes/android' },
27
27
  { basePath: '/api/desktop', modulePath: '../routes/desktop' },
28
+ { basePath: '/api/stream', modulePath: '../routes/stream' },
28
29
  { basePath: '/api/recordings', modulePath: '../routes/recordings' },
29
30
  { basePath: '/api/social-video', modulePath: '../routes/social_video' },
30
31
  { basePath: '/api/voice-assistant', modulePath: '../routes/voice_assistant' },
@@ -8,7 +8,7 @@ function createSocketServer(httpServer, { validateOrigin }) {
8
8
  pingInterval: Number(process.env.NEOAGENT_SOCKET_PING_INTERVAL_MS || 25000),
9
9
  pingTimeout: Number(process.env.NEOAGENT_SOCKET_PING_TIMEOUT_MS || 20000),
10
10
  connectTimeout: Number(process.env.NEOAGENT_SOCKET_CONNECT_TIMEOUT_MS || 10000),
11
- maxHttpBufferSize: Number(process.env.NEOAGENT_SOCKET_MAX_HTTP_BUFFER_BYTES || 1_000_000),
11
+ maxHttpBufferSize: Number(process.env.NEOAGENT_SOCKET_MAX_HTTP_BUFFER_BYTES || 8 * 1024 * 1024),
12
12
  cors: {
13
13
  origin(origin, callback) {
14
14
  return validateOrigin(origin, callback, { allowMissingOrigin: true });
package/server/index.js CHANGED
@@ -34,6 +34,7 @@ const { startServices, stopServices } = require('./services/manager');
34
34
  const { bindBrowserExtensionGateway } = require('./services/browser/extension/gateway');
35
35
  const { bindDesktopCompanionGateway } = require('./services/desktop/gateway');
36
36
  const { bindWearableGateway } = require('./services/wearable/gateway');
37
+ const { StreamHub } = require('./services/streaming/stream-hub');
37
38
 
38
39
  function parseBooleanFlag(value, fallback = false) {
39
40
  const normalized = String(value || '').trim().toLowerCase();
@@ -89,6 +90,8 @@ const app = express();
89
90
  app.disable('x-powered-by');
90
91
  const httpServer = createServer(app);
91
92
  const io = createSocketServer(httpServer, { validateOrigin });
93
+ const streamHub = new StreamHub(io);
94
+ app.locals.streamHub = streamHub;
92
95
  app.locals.httpRuntimeConfig = {
93
96
  secureCookies: SECURE_COOKIES,
94
97
  trustProxy: TRUST_PROXY,
@@ -112,7 +115,7 @@ registerApiRoutes(app);
112
115
  registerStaticRoutes(app);
113
116
  registerErrorHandler(app);
114
117
  bindBrowserExtensionGateway(httpServer, app);
115
- bindDesktopCompanionGateway(httpServer, app, sessionMiddleware);
118
+ bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamHub);
116
119
  bindWearableGateway(httpServer, app, sessionMiddleware);
117
120
 
118
121
  let shuttingDown = false;
@@ -1 +1 @@
1
- 547ecdc48f374b4b525ad319e60c921b
1
+ f09d02f54b922317d6897b53a29a5868
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"4c525dac5ebe5971c5708ef73558ed8edcf4a3
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "730484345" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "1854193544" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -131149,7 +131149,7 @@ r===$&&A.b()
131149
131149
  o.push(A.iq(p,A.j2(!1,new A.a4(B.u0,A.e_(new A.cN(B.hf,new A.a6E(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
131150
131150
  if(!s.ay)if(!s.ch){r=s.e
131151
131151
  r===$&&A.b()
131152
- r=B.b.v("mpeldus3-e9a9387").length!==0&&r.b}if(r){r=s.d
131152
+ r=B.b.v("mpem9vkq-e415031").length!==0&&r.b}if(r){r=s.d
131153
131153
  r===$&&A.b()
131154
131154
  r=r.ab&&!r.Z?84:0
131155
131155
  q=s.e
@@ -136201,7 +136201,7 @@ $S:338}
136201
136201
  A.Z6.prototype={}
136202
136202
  A.S_.prototype={
136203
136203
  n4(a){var s=this
136204
- if(B.b.v("mpeldus3-e9a9387").length===0||s.a!=null)return
136204
+ if(B.b.v("mpem9vkq-e415031").length===0||s.a!=null)return
136205
136205
  s.Aq()
136206
136206
  s.a=A.q9(B.Qi,new A.b7t(s))},
136207
136207
  Aq(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -136219,7 +136219,7 @@ if(!t.f.b(k)){s=1
136219
136219
  break}i=J.X(k,"buildId")
136220
136220
  h=i==null?null:B.b.v(J.q(i))
136221
136221
  j=h==null?"":h
136222
- if(J.bt(j)===0||J.c(j,"mpeldus3-e9a9387")){s=1
136222
+ if(J.bt(j)===0||J.c(j,"mpem9vkq-e415031")){s=1
136223
136223
  break}n.b=!0
136224
136224
  n.F()
136225
136225
  p=2
@@ -136236,7 +136236,7 @@ case 2:return A.i(o.at(-1),r)}})
136236
136236
  return A.k($async$Aq,r)},
136237
136237
  vl(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
136238
136238
  var $async$vl=A.h(function(a2,a3){if(a2===1){o.push(a3)
136239
- s=p}for(;;)switch(s){case 0:if(B.b.v("mpeldus3-e9a9387").length===0||n.c){s=1
136239
+ s=p}for(;;)switch(s){case 0:if(B.b.v("mpem9vkq-e415031").length===0||n.c){s=1
136240
136240
  break}n.c=!0
136241
136241
  n.F()
136242
136242
  p=4