Refine assistant attachment payload handling
This commit is contained in:
parent
052c00cd34
commit
ae3a8c02cc
98
lib/features/assistant/assistant_attachment_payloads.dart
Normal file
98
lib/features/assistant/assistant_attachment_payloads.dart
Normal 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';
|
||||
}
|
||||
@ -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(
|
||||
|
||||
108
test/features/assistant/assistant_attachment_payloads_test.dart
Normal file
108
test/features/assistant/assistant_attachment_payloads_test.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user