Refine assistant attachment payload handling

This commit is contained in:
Haitao Pan 2026-05-25 09:56:06 +08:00
parent 052c00cd34
commit ae3a8c02cc
4 changed files with 292 additions and 23 deletions

View File

@ -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<List<GatewayChatAttachmentPayload>>
buildAssistantAttachmentPayloadsInternal(
List<ComposerAttachmentInternal> attachments, {
int maxFileBytes = assistantAttachmentMaxFileBytesInternal,
int maxTotalBytes = assistantAttachmentMaxTotalBytesInternal,
}) async {
final payloads = <GatewayChatAttachmentPayload>[];
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';
}

View File

@ -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<GatewayChatAttachmentPayload> 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<List<GatewayChatAttachmentPayload>> buildAttachmentPayloadsInternal(
List<ComposerAttachmentInternal> attachments,
) async {
final payloads = <GatewayChatAttachmentPayload>[];
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(

View File

@ -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>[
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<int>.filled(6, 1));
await expectLater(
buildAssistantAttachmentPayloadsInternal(<ComposerAttachmentInternal>[
ComposerAttachmentInternal(
name: 'large.bin',
path: file.path,
icon: Icons.insert_drive_file_outlined,
mimeType: 'application/octet-stream',
),
], maxFileBytes: 5),
throwsA(
isA<AssistantAttachmentLimitException>()
.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>[
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<AssistantAttachmentLimitException>()
.having((error) => error.code, 'code', 'total')
.having((error) => error.fileName, 'fileName', 'b.txt'),
),
);
});
}

View File

@ -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>[
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<dynamic>;
final inlineAttachment =
inlineAttachments.single as Map<String, dynamic>;
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<dynamic>;
final attachment = attachments.single as Map<String, dynamic>;
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: <GatewayChatAttachmentPayload>[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(