| .. | .. |
|---|
| 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 | } |
|---|