feat: attach button with camera/gallery/files, atomic bundle send
| .. | .. |
|---|
| 671 | 671 | ); |
|---|
| 672 | 672 | if (result == null || result.files.isEmpty) return; |
|---|
| 673 | 673 | |
|---|
| 674 | + // Build attachments list |
|---|
| 675 | + final attachments = <Map<String, dynamic>>[]; |
|---|
| 674 | 676 | for (final file in result.files) { |
|---|
| 675 | 677 | if (file.path == null) continue; |
|---|
| 676 | 678 | final bytes = await File(file.path!).readAsBytes(); |
|---|
| 677 | 679 | final b64 = base64Encode(bytes); |
|---|
| 678 | 680 | final mimeType = _guessMimeType(file.name); |
|---|
| 679 | | - final isImage = mimeType.startsWith('image/'); |
|---|
| 680 | 681 | |
|---|
| 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( |
|---|
| 683 | 691 | role: MessageRole.user, |
|---|
| 684 | 692 | imageBase64: b64, |
|---|
| 685 | 693 | content: file.name, |
|---|
| 686 | 694 | 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 | + )); |
|---|
| 696 | 696 | } 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( |
|---|
| 703 | 698 | role: MessageRole.user, |
|---|
| 704 | 699 | content: '📎 ${file.name} (${_formatSize(bytes.length)})', |
|---|
| 705 | 700 | 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 | + )); |
|---|
| 717 | 702 | } |
|---|
| 718 | 703 | } |
|---|
| 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 | + |
|---|
| 719 | 713 | _scrollToBottom(); |
|---|
| 720 | 714 | } |
|---|
| 721 | 715 | |
|---|
| .. | .. |
|---|
| 839 | 833 | textCaption = ''; |
|---|
| 840 | 834 | } |
|---|
| 841 | 835 | |
|---|
| 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 |
|---|
| 844 | 851 | if (voiceB64 != null) { |
|---|
| 845 | 852 | final voiceMsg = Message.voice( |
|---|
| 846 | 853 | role: MessageRole.user, |
|---|
| .. | .. |
|---|
| 853 | 860 | 'audioBase64': voiceB64, |
|---|
| 854 | 861 | 'content': '', |
|---|
| 855 | 862 | '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, |
|---|
| 870 | 863 | 'sessionId': targetSessionId, |
|---|
| 871 | 864 | }); |
|---|
| 872 | 865 | } |
|---|
| .. | .. |
|---|
| 141 | 141 | client.keepAlivePeriod = 30; |
|---|
| 142 | 142 | client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success |
|---|
| 143 | 143 | client.connectTimeoutPeriod = timeout; |
|---|
| 144 | | - client.logging(on: true); |
|---|
| 144 | + // client.maxConnectionAttempts is final — can't set it |
|---|
| 145 | + client.logging(on: false); |
|---|
| 145 | 146 | |
|---|
| 146 | 147 | client.onConnected = _onConnected; |
|---|
| 147 | 148 | client.onDisconnected = _onDisconnected; |
|---|
| .. | .. |
|---|
| 407 | 408 | } |
|---|
| 408 | 409 | |
|---|
| 409 | 410 | if (type == 'image' && sessionId != null) { |
|---|
| 410 | | - // Image message |
|---|
| 411 | 411 | _publish('pailot/$sessionId/in', { |
|---|
| 412 | 412 | 'msgId': _uuid(), |
|---|
| 413 | 413 | 'type': 'image', |
|---|
| .. | .. |
|---|
| 420 | 420 | return; |
|---|
| 421 | 421 | } |
|---|
| 422 | 422 | |
|---|
| 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 | + |
|---|
| 423 | 451 | if (type == 'tts' && sessionId != null) { |
|---|
| 424 | 452 | // TTS request — route as command |
|---|
| 425 | 453 | _publish('pailot/control/in', { |
|---|