Matthias Nott
2026-03-23 78f101ac853aeea5d17067bec4215e070fc71fed
feat: attach button with camera/gallery/files, atomic bundle send
2 files modified
changed files
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/screens/chat_screen.dart
....@@ -671,51 +671,45 @@
671671 );
672672 if (result == null || result.files.isEmpty) return;
673673
674
+ // Build attachments list
675
+ final attachments = <Map<String, dynamic>>[];
674676 for (final file in result.files) {
675677 if (file.path == null) continue;
676678 final bytes = await File(file.path!).readAsBytes();
677679 final b64 = base64Encode(bytes);
678680 final mimeType = _guessMimeType(file.name);
679
- final isImage = mimeType.startsWith('image/');
680681
681
- if (isImage) {
682
- final message = Message.image(
682
+ attachments.add({
683
+ 'data': b64,
684
+ 'mimeType': mimeType,
685
+ 'fileName': file.name,
686
+ });
687
+
688
+ // Show in chat
689
+ if (mimeType.startsWith('image/')) {
690
+ ref.read(messagesProvider.notifier).addMessage(Message.image(
683691 role: MessageRole.user,
684692 imageBase64: b64,
685693 content: file.name,
686694 status: MessageStatus.sent,
687
- );
688
- ref.read(messagesProvider.notifier).addMessage(message);
689
- _ws?.send({
690
- 'type': 'image',
691
- 'imageBase64': b64,
692
- 'mimeType': mimeType,
693
- 'caption': file.name,
694
- 'sessionId': targetSessionId,
695
- });
695
+ ));
696696 } else {
697
- // Non-image file: send as text with file info, save file to temp for session
698
- final tmpDir = await getTemporaryDirectory();
699
- final tmpPath = '${tmpDir.path}/${file.name}';
700
- await File(tmpPath).writeAsBytes(bytes);
701
-
702
- final message = Message.text(
697
+ ref.read(messagesProvider.notifier).addMessage(Message.text(
703698 role: MessageRole.user,
704699 content: '📎 ${file.name} (${_formatSize(bytes.length)})',
705700 status: MessageStatus.sent,
706
- );
707
- ref.read(messagesProvider.notifier).addMessage(message);
708
- // Send file as base64 with metadata
709
- _ws?.send({
710
- 'type': 'file',
711
- 'fileBase64': b64,
712
- 'fileName': file.name,
713
- 'mimeType': mimeType,
714
- 'fileSize': bytes.length,
715
- 'sessionId': targetSessionId,
716
- });
701
+ ));
717702 }
718703 }
704
+
705
+ // Send all files as one atomic bundle
706
+ _ws?.send({
707
+ 'type': 'bundle',
708
+ 'caption': '',
709
+ 'attachments': attachments,
710
+ 'sessionId': targetSessionId,
711
+ });
712
+
719713 _scrollToBottom();
720714 }
721715
....@@ -839,8 +833,21 @@
839833 textCaption = '';
840834 }
841835
842
- // Send voice FIRST so Whisper transcribes it and the [PAILot:voice] prefix
843
- // sets the reply channel. Images follow — Claude sees transcript + images together.
836
+ // Send everything as a single atomic bundle — the server will compose
837
+ // all attachments + caption into one terminal input (like dropping files + text)
838
+ final attachments = encodedImages.map((b64) =>
839
+ <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}
840
+ ).toList();
841
+
842
+ _ws?.send({
843
+ 'type': 'bundle',
844
+ 'caption': textCaption,
845
+ if (voiceB64 != null) 'audioBase64': voiceB64,
846
+ 'attachments': attachments,
847
+ 'sessionId': targetSessionId,
848
+ });
849
+
850
+ // If voice caption, also send separately for Whisper transcription
844851 if (voiceB64 != null) {
845852 final voiceMsg = Message.voice(
846853 role: MessageRole.user,
....@@ -853,20 +860,6 @@
853860 'audioBase64': voiceB64,
854861 'content': '',
855862 'messageId': voiceMsg.id,
856
- 'sessionId': targetSessionId,
857
- });
858
- }
859
-
860
- // Send images — first with text caption (if any), rest without
861
- for (var i = 0; i < encodedImages.length; i++) {
862
- final isFirst = i == 0;
863
- final msgCaption = isFirst ? textCaption : '';
864
-
865
- _ws?.send({
866
- 'type': 'image',
867
- 'imageBase64': encodedImages[i],
868
- 'mimeType': 'image/jpeg',
869
- 'caption': msgCaption,
870863 'sessionId': targetSessionId,
871864 });
872865 }
lib/services/mqtt_service.dart
....@@ -141,7 +141,8 @@
141141 client.keepAlivePeriod = 30;
142142 client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
143143 client.connectTimeoutPeriod = timeout;
144
- client.logging(on: true);
144
+ // client.maxConnectionAttempts is final — can't set it
145
+ client.logging(on: false);
145146
146147 client.onConnected = _onConnected;
147148 client.onDisconnected = _onDisconnected;
....@@ -407,7 +408,6 @@
407408 }
408409
409410 if (type == 'image' && sessionId != null) {
410
- // Image message
411411 _publish('pailot/$sessionId/in', {
412412 'msgId': _uuid(),
413413 'type': 'image',
....@@ -420,6 +420,34 @@
420420 return;
421421 }
422422
423
+ if (type == 'bundle' && sessionId != null) {
424
+ // Atomic multi-attachment message
425
+ _publish('pailot/$sessionId/in', {
426
+ 'msgId': _uuid(),
427
+ 'type': 'bundle',
428
+ 'sessionId': sessionId,
429
+ 'caption': message['caption'] ?? '',
430
+ if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
431
+ 'attachments': message['attachments'] ?? [],
432
+ 'ts': _now(),
433
+ }, MqttQos.atLeastOnce);
434
+ return;
435
+ }
436
+
437
+ if (type == 'file' && sessionId != null) {
438
+ _publish('pailot/$sessionId/in', {
439
+ 'msgId': _uuid(),
440
+ 'type': 'file',
441
+ 'sessionId': sessionId,
442
+ 'fileBase64': message['fileBase64'] ?? '',
443
+ 'fileName': message['fileName'] ?? 'file',
444
+ 'mimeType': message['mimeType'] ?? 'application/octet-stream',
445
+ 'fileSize': message['fileSize'] ?? 0,
446
+ 'ts': _now(),
447
+ }, MqttQos.atLeastOnce);
448
+ return;
449
+ }
450
+
423451 if (type == 'tts' && sessionId != null) {
424452 // TTS request — route as command
425453 _publish('pailot/control/in', {