From 42dda13aa2eb4b7f17533934a3534e47fcf314da Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 25 May 2026 09:56:06 +0800 Subject: [PATCH] Refine assistant attachment payload handling --- .../assistant_attachment_payloads.dart | 98 ++++++++++++++++ .../assistant_page_state_actions.dart | 52 +++++---- .../assistant_attachment_payloads_test.dart | 108 ++++++++++++++++++ .../assistant_execution_target_test.dart | 57 ++++++++- 4 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 lib/features/assistant/assistant_attachment_payloads.dart create mode 100644 test/features/assistant/assistant_attachment_payloads_test.dart diff --git a/lib/features/assistant/assistant_attachment_payloads.dart b/lib/features/assistant/assistant_attachment_payloads.dart new file mode 100644 index 00000000..5a7f551c --- /dev/null +++ b/lib/features/assistant/assistant_attachment_payloads.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../runtime/runtime_models.dart'; +import 'assistant_page_composer_clipboard.dart'; + +const int assistantAttachmentMaxFileBytesInternal = 10 * 1024 * 1024; +const int assistantAttachmentMaxTotalBytesInternal = 25 * 1024 * 1024; + +class AssistantAttachmentLimitException implements Exception { + const AssistantAttachmentLimitException({ + required this.code, + required this.fileName, + required this.sizeBytes, + required this.limitBytes, + }); + + final String code; + final String fileName; + final int sizeBytes; + final int limitBytes; + + @override + String toString() => + 'AssistantAttachmentLimitException($code, $fileName, $sizeBytes, $limitBytes)'; +} + +Future> +buildAssistantAttachmentPayloadsInternal( + List attachments, { + int maxFileBytes = assistantAttachmentMaxFileBytesInternal, + int maxTotalBytes = assistantAttachmentMaxTotalBytesInternal, +}) async { + final payloads = []; + var totalBytes = 0; + for (final attachment in attachments) { + final file = File(attachment.path); + if (!await file.exists()) { + continue; + } + final stat = await file.stat(); + final sizeBytes = stat.size; + if (sizeBytes > maxFileBytes) { + throw AssistantAttachmentLimitException( + code: 'file', + fileName: attachment.name, + sizeBytes: sizeBytes, + limitBytes: maxFileBytes, + ); + } + if (totalBytes + sizeBytes > maxTotalBytes) { + throw AssistantAttachmentLimitException( + code: 'total', + fileName: attachment.name, + sizeBytes: totalBytes + sizeBytes, + limitBytes: maxTotalBytes, + ); + } + final bytes = await file.readAsBytes(); + if (bytes.length > maxFileBytes) { + throw AssistantAttachmentLimitException( + code: 'file', + fileName: attachment.name, + sizeBytes: bytes.length, + limitBytes: maxFileBytes, + ); + } + if (totalBytes + bytes.length > maxTotalBytes) { + throw AssistantAttachmentLimitException( + code: 'total', + fileName: attachment.name, + sizeBytes: totalBytes + bytes.length, + limitBytes: maxTotalBytes, + ); + } + totalBytes += bytes.length; + final mimeType = attachment.mimeType; + payloads.add( + GatewayChatAttachmentPayload( + type: mimeType.startsWith('image/') ? 'image' : 'file', + mimeType: mimeType, + fileName: attachment.name, + content: base64Encode(bytes), + ), + ); + } + return payloads; +} + +String formatAssistantAttachmentBytesInternal(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index ce75132b..ba6acdd9 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -1,8 +1,6 @@ // ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; @@ -37,6 +35,7 @@ import 'assistant_page_task_models.dart'; import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; +import 'assistant_attachment_payloads.dart'; import 'assistant_page_components_core.dart'; extension AssistantPageStateActionsInternal on AssistantPageStateInternal { @@ -102,6 +101,20 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { attachmentsInternal, growable: false, ); + final List attachmentPayloads; + try { + attachmentPayloads = await buildAttachmentPayloadsInternal( + submittedAttachments, + ); + } on AssistantAttachmentLimitException catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(attachmentLimitMessageInternal(error))), + ); + return; + } final attachmentNames = submittedAttachments .map((item) => item.name) .toList(growable: false); @@ -138,9 +151,6 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { inputControllerInternal.clear(); try { - final attachmentPayloads = await buildAttachmentPayloadsInternal( - submittedAttachments, - ); await controller.sendChatMessage( prompt, thinking: thinkingLabelInternal, @@ -178,25 +188,23 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { Future> buildAttachmentPayloadsInternal( List attachments, - ) async { - final payloads = []; - for (final attachment in attachments) { - final file = File(attachment.path); - if (!await file.exists()) { - continue; - } - final bytes = await file.readAsBytes(); - final mimeType = attachment.mimeType; - payloads.add( - GatewayChatAttachmentPayload( - type: mimeType.startsWith('image/') ? 'image' : 'file', - mimeType: mimeType, - fileName: attachment.name, - content: base64Encode(bytes), - ), + ) => buildAssistantAttachmentPayloadsInternal(attachments); + + String attachmentLimitMessageInternal( + AssistantAttachmentLimitException error, + ) { + final size = formatAssistantAttachmentBytesInternal(error.sizeBytes); + final limit = formatAssistantAttachmentBytesInternal(error.limitBytes); + if (error.code == 'total') { + return appText( + '附件总大小 $size 超过单次提交上限 $limit,请移除部分文件后再提交。', + 'Attachments total $size exceeds the per-message limit of $limit. Remove some files and try again.', ); } - return payloads; + return appText( + '附件 ${error.fileName} 为 $size,超过单文件上限 $limit。', + 'Attachment ${error.fileName} is $size, above the per-file limit of $limit.', + ); } GatewayAgentSummary? pickAutoAgentInternal( diff --git a/test/features/assistant/assistant_attachment_payloads_test.dart b/test/features/assistant/assistant_attachment_payloads_test.dart new file mode 100644 index 00000000..d6bd4ffc --- /dev/null +++ b/test/features/assistant/assistant_attachment_payloads_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/assistant/assistant_attachment_payloads.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; + +void main() { + test('builds base64 inline attachment payloads with content', () async { + final directory = await Directory.systemTemp.createTemp( + 'xworkmate-attachment-payloads-', + ); + addTearDown(() async { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + }); + final file = File('${directory.path}/note.txt'); + await file.writeAsString('hello attachment'); + + final payloads = await buildAssistantAttachmentPayloadsInternal( + [ + ComposerAttachmentInternal( + name: 'note.txt', + path: file.path, + icon: Icons.description_outlined, + mimeType: 'text/plain', + ), + ], + ); + + expect(payloads, hasLength(1)); + expect(payloads.single.fileName, 'note.txt'); + expect(payloads.single.mimeType, 'text/plain'); + expect(payloads.single.type, 'file'); + expect( + base64Decode(payloads.single.content), + utf8.encode('hello attachment'), + ); + }); + + test('rejects a single attachment above the per-file limit', () async { + final directory = await Directory.systemTemp.createTemp( + 'xworkmate-attachment-file-limit-', + ); + addTearDown(() async { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + }); + final file = File('${directory.path}/large.bin'); + await file.writeAsBytes(List.filled(6, 1)); + + await expectLater( + buildAssistantAttachmentPayloadsInternal([ + ComposerAttachmentInternal( + name: 'large.bin', + path: file.path, + icon: Icons.insert_drive_file_outlined, + mimeType: 'application/octet-stream', + ), + ], maxFileBytes: 5), + throwsA( + isA() + .having((error) => error.code, 'code', 'file') + .having((error) => error.fileName, 'fileName', 'large.bin'), + ), + ); + }); + + test('rejects attachments above the per-message total limit', () async { + final directory = await Directory.systemTemp.createTemp( + 'xworkmate-attachment-total-limit-', + ); + addTearDown(() async { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + }); + final first = File('${directory.path}/a.txt'); + final second = File('${directory.path}/b.txt'); + await first.writeAsString('1234'); + await second.writeAsString('5678'); + + await expectLater( + buildAssistantAttachmentPayloadsInternal([ + ComposerAttachmentInternal( + name: 'a.txt', + path: first.path, + icon: Icons.description_outlined, + mimeType: 'text/plain', + ), + ComposerAttachmentInternal( + name: 'b.txt', + path: second.path, + icon: Icons.description_outlined, + mimeType: 'text/plain', + ), + ], maxTotalBytes: 7), + throwsA( + isA() + .having((error) => error.code, 'code', 'total') + .having((error) => error.fileName, 'fileName', 'b.txt'), + ), + ); + }); +} diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 509d6eae..a3f75cb7 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1132,6 +1132,46 @@ void main() { ); }); + test( + 'sendChatMessage forwards inline attachment content and size', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedController(fakeGoTaskService); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('attachment-task'); + await controller.sendChatMessage( + 'use attachment', + attachments: [ + GatewayChatAttachmentPayload( + type: 'file', + mimeType: 'text/plain', + fileName: 'note.txt', + content: base64Encode(utf8.encode('note body')), + ), + ], + ); + + final request = fakeGoTaskService.requests.single; + expect(request.inlineAttachments, hasLength(1)); + final params = request.toExternalAcpParams(); + final inlineAttachments = params['inlineAttachments'] as List; + final inlineAttachment = + inlineAttachments.single as Map; + expect(inlineAttachment['name'], 'note.txt'); + expect(inlineAttachment['mimeType'], 'text/plain'); + expect( + inlineAttachment['content'], + base64Encode(utf8.encode('note body')), + ); + expect(inlineAttachment['sizeBytes'], 9); + final attachments = params['attachments'] as List; + final attachment = attachments.single as Map; + expect(attachment['name'], 'note.txt'); + expect(attachment['path'], isEmpty); + }, + ); + test( 'sendChatMessage resumes existing task after response interruption', () async { @@ -2390,7 +2430,16 @@ void main() { ); await _selectGatewaySession(controller, 'queue-task-b'); - final taskBFuture = controller.sendChatMessage('same prompt'); + final queuedAttachment = GatewayChatAttachmentPayload( + type: 'file', + mimeType: 'text/plain', + fileName: 'queued.txt', + content: base64Encode(utf8.encode('queued content')), + ); + final taskBFuture = controller.sendChatMessage( + 'same prompt', + attachments: [queuedAttachment], + ); await _selectGatewaySession(controller, 'queue-task-c'); final taskCFuture = controller.sendChatMessage('different prompt'); await _waitForThreadLifecycleStatus( @@ -2473,6 +2522,12 @@ void main() { expect(taskBRequest.prompt, contains('- sessionKey: queue-task-b')); expect(taskBRequest.prompt, contains('User request:\nsame prompt')); expect(taskBRequest.resumeSession, isFalse); + expect(taskBRequest.inlineAttachments, hasLength(1)); + expect(taskBRequest.inlineAttachments.single.fileName, 'queued.txt'); + expect( + taskBRequest.inlineAttachments.single.content, + queuedAttachment.content, + ); expect(taskBRequest.workingDirectory, endsWith('/queue-task-b')); expect(taskBRequest.prompt, contains(taskBRequest.workingDirectory)); expect(