neoagent 2.3.1-beta.29 → 2.3.1-beta.30

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.
@@ -664,7 +664,9 @@ class _MessagingPanelState extends State<MessagingPanel> {
664
664
  final textControllers = <String, TextEditingController>{};
665
665
  final boolValues = <String, bool>{};
666
666
  for (final field in platform.configFields) {
667
- final savedValue = saved[field.key];
667
+ final savedValue = field.settingsKey == null
668
+ ? saved[field.key]
669
+ : widget.controller.settings[field.storageKey];
668
670
  if (field.kind == MessagingConfigFieldKind.boolean) {
669
671
  boolValues[field.key] =
670
672
  savedValue == true || savedValue?.toString() == 'true';
@@ -736,10 +738,22 @@ class _MessagingPanelState extends State<MessagingPanel> {
736
738
  );
737
739
  }),
738
740
  const SizedBox(height: 8),
739
- SelectableText(
740
- 'Inbound webhook: ${widget.controller.backendUrl}/api/messaging/webhook/${platform.id}',
741
- style: TextStyle(color: _textSecondary, fontSize: 12),
742
- ),
741
+ if (platform.id == 'meshtastic')
742
+ Text(
743
+ 'Meshtastic connects directly to the device TCP API on port 4403 by default. Normal chat is limited to the configured channel.',
744
+ style: TextStyle(
745
+ color: _textSecondary,
746
+ fontSize: 12,
747
+ ),
748
+ )
749
+ else
750
+ SelectableText(
751
+ 'Inbound webhook: ${widget.controller.backendUrl}/api/messaging/webhook/${platform.id}',
752
+ style: TextStyle(
753
+ color: _textSecondary,
754
+ fontSize: 12,
755
+ ),
756
+ ),
743
757
  ],
744
758
  ),
745
759
  ),
@@ -752,26 +766,84 @@ class _MessagingPanelState extends State<MessagingPanel> {
752
766
  FilledButton(
753
767
  onPressed: () async {
754
768
  final config = <String, dynamic>{};
755
- for (final entry in textControllers.entries) {
756
- final value = entry.value.text.trim();
757
- if (value.isNotEmpty) config[entry.key] = value;
769
+ final snapshot = <String, dynamic>{};
770
+ for (final field in platform.configFields) {
771
+ if (field.kind == MessagingConfigFieldKind.boolean ||
772
+ !field.includeInConfig) {
773
+ continue;
774
+ }
775
+ final controller = textControllers[field.key];
776
+ final value = controller?.text.trim() ?? '';
777
+ if (value.isNotEmpty) config[field.key] = value;
758
778
  }
759
- for (final entry in boolValues.entries) {
760
- config[entry.key] = entry.value;
779
+ for (final field in platform.configFields) {
780
+ if (field.kind == MessagingConfigFieldKind.boolean) {
781
+ final value = boolValues[field.key] ?? false;
782
+ if (field.includeInConfig) {
783
+ config[field.key] = value;
784
+ }
785
+ if (field.settingsKey != null) {
786
+ snapshot[field.storageKey] = value;
787
+ }
788
+ } else if (field.settingsKey != null) {
789
+ final controller = textControllers[field.key];
790
+ final value = controller?.text.trim() ?? '';
791
+ if (value.isNotEmpty) {
792
+ snapshot[field.storageKey] = value;
793
+ }
794
+ }
795
+ }
796
+ snapshot[platform.settingsKey] = jsonEncode(config);
797
+ final meshtasticEnabled =
798
+ platform.id != 'meshtastic' ||
799
+ (boolValues['meshtastic_enabled'] ?? true);
800
+ var connected = false;
801
+ if (meshtasticEnabled) {
802
+ connected = await _connectMessagingPlatform(
803
+ platform: platform.id,
804
+ platformLabel: platform.label,
805
+ config: config,
806
+ configSnapshot: snapshot,
807
+ );
808
+ } else {
809
+ final messenger = ScaffoldMessenger.maybeOf(context);
810
+ try {
811
+ await widget.controller.saveSettingsPayload(snapshot);
812
+ } catch (error) {
813
+ if (!mounted) return;
814
+ messenger?.showSnackBar(
815
+ SnackBar(
816
+ content: Text(
817
+ 'Failed to save ${platform.label}: ${widget.controller.friendlyErrorMessage(error)}',
818
+ ),
819
+ ),
820
+ );
821
+ return;
822
+ }
823
+ try {
824
+ await widget.controller.refreshMessaging();
825
+ connected = true;
826
+ } catch (error) {
827
+ if (!mounted) return;
828
+ messenger?.showSnackBar(
829
+ SnackBar(
830
+ content: Text(
831
+ 'Saved ${platform.label}, but refresh failed: ${widget.controller.friendlyErrorMessage(error)}',
832
+ ),
833
+ ),
834
+ );
835
+ }
761
836
  }
762
- final connected = await _connectMessagingPlatform(
763
- platform: platform.id,
764
- platformLabel: platform.label,
765
- config: config,
766
- configSnapshot: <String, dynamic>{
767
- platform.settingsKey: jsonEncode(config),
768
- },
769
- );
770
837
  if (connected && context.mounted) {
771
838
  Navigator.of(context).pop();
772
839
  }
773
840
  },
774
- child: Text('Connect'),
841
+ child: Text(
842
+ platform.id == 'meshtastic' &&
843
+ !(boolValues['meshtastic_enabled'] ?? true)
844
+ ? 'Save'
845
+ : 'Connect',
846
+ ),
775
847
  ),
776
848
  ],
777
849
  );
@@ -5009,6 +5009,29 @@ class NeoAgentController extends ChangeNotifier {
5009
5009
  await refreshMessaging();
5010
5010
  }
5011
5011
 
5012
+ Future<void> saveSettingsPayload(Map<String, dynamic> payload) async {
5013
+ if (isSavingSettings) {
5014
+ return;
5015
+ }
5016
+ isSavingSettings = true;
5017
+ errorMessage = null;
5018
+ notifyListeners();
5019
+ try {
5020
+ await _backendClient.saveSettings(
5021
+ backendUrl,
5022
+ payload,
5023
+ agentId: _scopedAgentId,
5024
+ );
5025
+ settings = <String, dynamic>{...settings, ...payload};
5026
+ } catch (error) {
5027
+ errorMessage = _friendlyErrorMessage(error);
5028
+ rethrow;
5029
+ } finally {
5030
+ isSavingSettings = false;
5031
+ notifyListeners();
5032
+ }
5033
+ }
5034
+
5012
5035
  Future<void> disconnectMessagingPlatform(String platform) async {
5013
5036
  await _backendClient.disconnectMessagingPlatform(
5014
5037
  backendUrl,
@@ -6212,6 +6235,9 @@ class NeoAgentController extends ChangeNotifier {
6212
6235
  }
6213
6236
  final content = payload['content']?.toString() ?? '';
6214
6237
  final kind = payload['kind']?.toString() ?? 'final';
6238
+ if (content.trim().isEmpty) {
6239
+ return;
6240
+ }
6215
6241
  voiceAssistantLiveState = voiceAssistantLiveState.copyWith(
6216
6242
  interimAssistantText: kind == 'final'
6217
6243
  ? voiceAssistantLiveState.interimAssistantText
@@ -270,6 +270,29 @@ messagingPlatforms = <MessagingPlatformDescriptor>[
270
270
  ),
271
271
  ],
272
272
  ),
273
+ MessagingPlatformDescriptor(
274
+ id: 'meshtastic',
275
+ label: 'Meshtastic',
276
+ subtitle: 'TCP bridge to a local device channel',
277
+ accent: Color(0xFF2E7D32),
278
+ connectMethod: MessagingConnectMethod.config,
279
+ icon: Icons.router_rounded,
280
+ configFields: <MessagingConfigField>[
281
+ MessagingConfigField(
282
+ key: 'meshtastic_enabled',
283
+ label: 'Enable Meshtastic on this server',
284
+ kind: MessagingConfigFieldKind.boolean,
285
+ settingsKey: 'meshtastic_enabled',
286
+ includeInConfig: false,
287
+ ),
288
+ MessagingConfigField(key: 'host', label: 'Device IP Address'),
289
+ MessagingConfigField(
290
+ key: 'channel',
291
+ label: 'Channel Index',
292
+ defaultValue: '0',
293
+ ),
294
+ ],
295
+ ),
273
296
  MessagingPlatformDescriptor(
274
297
  id: 'telnyx',
275
298
  label: 'Telnyx Voice',
@@ -413,6 +436,8 @@ class MessagingConfigField {
413
436
  this.kind = MessagingConfigFieldKind.text,
414
437
  this.obscure = false,
415
438
  this.defaultValue,
439
+ this.settingsKey,
440
+ this.includeInConfig = true,
416
441
  });
417
442
 
418
443
  final String key;
@@ -420,6 +445,10 @@ class MessagingConfigField {
420
445
  final MessagingConfigFieldKind kind;
421
446
  final bool obscure;
422
447
  final String? defaultValue;
448
+ final String? settingsKey;
449
+ final bool includeInConfig;
450
+
451
+ String get storageKey => settingsKey ?? key;
423
452
  }
424
453
 
425
454
  class MessagingPlatformDescriptor {
@@ -41,12 +41,25 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
41
41
  @override
42
42
  Widget build(BuildContext context) {
43
43
  final runtime = widget.controller.recordingRuntime;
44
- final anyRecordingActive = runtime.active;
44
+ final isStarting = widget.controller.isStartingRecording;
45
+ final isStopping = widget.controller.isStoppingRecording;
46
+ final statusLabel = isStarting
47
+ ? 'Starting'
48
+ : isStopping
49
+ ? 'Stopping'
50
+ : runtime.active
51
+ ? (runtime.paused ? 'Paused' : 'Recording')
52
+ : 'Ready';
53
+ final statusColor = isStarting || isStopping
54
+ ? _accent
55
+ : runtime.active
56
+ ? (runtime.paused ? _warning : _danger)
57
+ : _success;
45
58
 
46
59
  return ListView(
47
60
  padding: _pagePadding(context),
48
61
  children: <Widget>[
49
- const _SectionTitle('Record meetings and conversations'),
62
+ const _SectionTitle('Recordings'),
50
63
  const SizedBox(height: 12),
51
64
  Card(
52
65
  child: Padding(
@@ -60,12 +73,8 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
60
73
  crossAxisAlignment: WrapCrossAlignment.center,
61
74
  children: <Widget>[
62
75
  _DotStatus(
63
- label: anyRecordingActive
64
- ? (runtime.paused ? 'Paused' : 'Recording')
65
- : 'Idle',
66
- color: anyRecordingActive
67
- ? (runtime.paused ? _warning : _danger)
68
- : _success,
76
+ label: statusLabel,
77
+ color: statusColor,
69
78
  ),
70
79
  if (runtime.platformLabel != null &&
71
80
  runtime.platformLabel!.isNotEmpty)
@@ -76,12 +85,57 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
76
85
  ],
77
86
  ),
78
87
  const SizedBox(height: 16),
79
- Text(
80
- runtime.supportsSystemAudio
81
- ? 'Desktop studio mode keeps microphone and system audio as separate live sources, supports background runtime, and exposes a floating control bar for long-running captures.'
82
- : 'Choose the best capture mode for the current platform. Existing web and Android flows remain available alongside the new desktop runtime.',
83
- style: TextStyle(color: _textSecondary, height: 1.5),
84
- ),
88
+ if (isStarting) ...<Widget>[
89
+ const SizedBox(height: 14),
90
+ Container(
91
+ width: double.infinity,
92
+ padding: const EdgeInsets.symmetric(
93
+ horizontal: 14,
94
+ vertical: 12,
95
+ ),
96
+ decoration: BoxDecoration(
97
+ color: _bgSecondary.withValues(alpha: 0.8),
98
+ borderRadius: BorderRadius.circular(16),
99
+ border: Border.all(color: _borderLight),
100
+ ),
101
+ child: Row(
102
+ children: <Widget>[
103
+ const SizedBox.square(
104
+ dimension: 16,
105
+ child: CircularProgressIndicator(strokeWidth: 2),
106
+ ),
107
+ const SizedBox(width: 12),
108
+ Expanded(
109
+ child: Text(
110
+ 'Starting recording. This can take a few seconds while the session and permissions are prepared.',
111
+ style: TextStyle(
112
+ color: _textSecondary,
113
+ height: 1.4,
114
+ ),
115
+ ),
116
+ ),
117
+ ],
118
+ ),
119
+ ),
120
+ ] else if (isStopping) ...<Widget>[
121
+ const SizedBox(height: 14),
122
+ Container(
123
+ width: double.infinity,
124
+ padding: const EdgeInsets.symmetric(
125
+ horizontal: 14,
126
+ vertical: 12,
127
+ ),
128
+ decoration: BoxDecoration(
129
+ color: _bgSecondary.withValues(alpha: 0.8),
130
+ borderRadius: BorderRadius.circular(16),
131
+ border: Border.all(color: _borderLight),
132
+ ),
133
+ child: Text(
134
+ 'Finalizing recording...',
135
+ style: TextStyle(color: _textSecondary, height: 1.4),
136
+ ),
137
+ ),
138
+ ],
85
139
  const SizedBox(height: 18),
86
140
  Wrap(
87
141
  spacing: 12,
@@ -95,7 +149,7 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
95
149
  ? null
96
150
  : widget.controller.startWebRecording,
97
151
  icon: Icon(Icons.desktop_windows_outlined),
98
- label: Text('Start screen + mic'),
152
+ label: Text('Screen + mic'),
99
153
  ),
100
154
  if (runtime.supportsScreenAndMic)
101
155
  OutlinedButton.icon(
@@ -115,7 +169,7 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
115
169
  ? null
116
170
  : widget.controller.startBackgroundRecording,
117
171
  icon: Icon(Icons.mic_none_outlined),
118
- label: Text('Start background mic'),
172
+ label: Text('Background mic'),
119
173
  ),
120
174
  if (runtime.supportsSystemAudio)
121
175
  FilledButton.icon(
@@ -127,7 +181,7 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
127
181
  foregroundColor: Colors.white,
128
182
  ),
129
183
  icon: Icon(Icons.surround_sound_outlined),
130
- label: Text('Start desktop studio'),
184
+ label: Text('Desktop studio'),
131
185
  ),
132
186
  if (runtime.supportsBackgroundMic && runtime.active)
133
187
  OutlinedButton.icon(
@@ -206,7 +260,7 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
206
260
  ),
207
261
  const SizedBox(height: 6),
208
262
  Text(
209
- 'Permissions, live levels, and background runtime state stay visible here while the floating bar handles quick controls.',
263
+ 'Permissions and live levels stay visible while the floating bar handles quick controls.',
210
264
  style: TextStyle(color: _textSecondary, height: 1.45),
211
265
  ),
212
266
  const SizedBox(height: 16),
@@ -317,13 +371,12 @@ class _RecordingsPanelState extends State<RecordingsPanel> {
317
371
  ),
318
372
  ),
319
373
  const SizedBox(height: 20),
320
- const _SectionTitle('Transcription history'),
374
+ const _SectionTitle('Transcripts'),
321
375
  const SizedBox(height: 12),
322
376
  if (widget.controller.recordingSessions.isEmpty)
323
377
  const _EmptyCard(
324
378
  title: 'No recordings yet',
325
- subtitle:
326
- 'Start a recording and your persisted transcripts will appear here with timestamps.',
379
+ subtitle: 'Start one and transcripts will appear here.',
327
380
  )
328
381
  else
329
382
  ...widget.controller.recordingSessions.map(
@@ -466,7 +519,7 @@ class _RecordingSessionCard extends StatelessWidget {
466
519
  Icon(Icons.auto_awesome, size: 16, color: _accent),
467
520
  const SizedBox(width: 8),
468
521
  Text(
469
- 'Smart Segments',
522
+ 'Insights',
470
523
  style: TextStyle(
471
524
  color: _accent,
472
525
  fontWeight: FontWeight.w600,
@@ -612,16 +665,28 @@ class _RecordingSessionCard extends StatelessWidget {
612
665
  ),
613
666
  ] else if (session.transcriptText.isNotEmpty) ...<Widget>[
614
667
  const SizedBox(height: 16),
615
- Text(session.transcriptText, style: TextStyle(height: 1.45)),
668
+ Container(
669
+ width: double.infinity,
670
+ padding: const EdgeInsets.all(14),
671
+ decoration: BoxDecoration(
672
+ color: _bgSecondary,
673
+ borderRadius: BorderRadius.circular(14),
674
+ border: Border.all(color: _border),
675
+ ),
676
+ child: SelectableText(
677
+ session.transcriptText,
678
+ style: TextStyle(height: 1.45),
679
+ ),
680
+ ),
616
681
  ] else ...<Widget>[
617
682
  const SizedBox(height: 16),
618
683
  Text(
619
684
  session.status == 'processing'
620
- ? 'Transcription is being processed.'
685
+ ? 'Transcribing...'
621
686
  : session.status == 'failed'
622
687
  ? 'Transcription failed. Check the error above and retry.'
623
688
  : session.status == 'completed'
624
- ? 'Transcription completed but no speech text was returned. You can retry transcription.'
689
+ ? 'No transcript text was returned. You can retry transcription.'
625
690
  : 'Transcript is not available yet.',
626
691
  style: TextStyle(color: _textSecondary),
627
692
  ),