feat: 添加 Agent 步骤事件与图片附件功能

- 新增 stepStarted/stepFinished 事件类型支持
- 前端实现图片附件上传和预览功能
- 后端增强工具结果存储和事件处理
- 完善相关单元测试和集成测试
This commit is contained in:
zl-q
2026-03-12 09:29:57 +08:00
parent 87215f9d41
commit 7b8865e256
45 changed files with 3869 additions and 308 deletions
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
@@ -265,6 +266,71 @@ void main() {
expect(first['content'], '只发送当前输入');
});
test('sendMessage uploads images then posts binary url blocks', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
var uploadCalls = 0;
final uploadedPath = 'agent-inputs/user/thread-1/upload-1.png';
client.registerHandler('/api/v1/agent/attachments', 'POST', (request) {
uploadCalls += 1;
return {
'attachment': {
'bucket': 'bucket-test',
'path': uploadedPath,
'mimeType': 'image/png',
'url': 'https://signed.example/$uploadedPath',
},
};
});
Map<String, dynamic>? postedRunInput;
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
postedRunInput = request.data as Map<String, dynamic>;
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
final image = XFile.fromData(
Uint8List.fromList(<int>[1, 2, 3]),
mimeType: 'image/png',
name: 'demo.png',
);
await service.sendMessage('图文消息', images: [image]);
expect(uploadCalls, 1);
expect(postedRunInput, isNotNull);
final messages = postedRunInput!['messages'] as List<dynamic>;
final first = messages.first as Map<String, dynamic>;
final content = first['content'] as List<dynamic>;
expect((content.first as Map<String, dynamic>)['type'], 'text');
expect((content[1] as Map<String, dynamic>)['type'], 'binary');
expect(
(content[1] as Map<String, dynamic>)['url'],
'https://signed.example/$uploadedPath',
);
final forwardedProps =
postedRunInput!['forwardedProps'] as Map<String, dynamic>;
final attachments = forwardedProps['attachments'] as List<dynamic>;
expect((attachments.first as Map<String, dynamic>)['path'], uploadedPath);
});
test('approveToolCall posts only tool message to resume API', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
@@ -482,5 +548,56 @@ void main() {
expect(seenLastEventIds[0], isNull);
expect(seenLastEventIds[1], '2-0');
});
test('stream parses backend TOOL_CALL_RESULT payload with ui field', () async {
final events = <AgUiEvent>[];
final client = MockApiClient();
final service = AgUiService(onEvent: events.add, apiClient: client);
client.clearMocks();
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'event: TOOL_CALL_RESULT',
'data: {"type":"TOOL_CALL_RESULT","messageId":"tool-result-1","toolCallId":"call-1","callId":"call-1","toolName":"calendar_write","result":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"ui":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"content":"已创建日程:项目评审(明天 10:00)"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
await service.sendMessage('创建日程');
final result = events.whereType<ToolCallResultEvent>().toList();
expect(result.length, 1);
expect(result.first.ui?.cardType, 'calendar_operation.v1');
});
test('fetchAttachmentPreview returns binary bytes', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
client.registerHandler(
'/api/v1/agent/runs/t1/attachments/m1/0',
'GET',
(_) => <int>[1, 2, 3, 4],
);
final data = await service.fetchAttachmentPreview(
'/api/v1/agent/runs/t1/attachments/m1/0',
);
expect(data, [1, 2, 3, 4]);
});
});
}
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
@@ -9,8 +11,17 @@ import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
class MockAgUiService extends AgUiService {
MockAgUiService() : super(onEvent: (_) {});
int previewCalls = 0;
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
previewCalls += 1;
await Future<void>.delayed(const Duration(milliseconds: 10));
return Uint8List.fromList(<int>[1, 2, 3]);
}
}
class _ThrowingAgUiService extends AgUiService {
@@ -182,6 +193,23 @@ void main() {
],
);
blocTest<ChatBloc, ChatState>(
'step events update currentStage',
build: () => chatBloc,
act: (bloc) {
service.onEvent(StepStartedEvent(stepName: 'execution'));
service.onEvent(StepFinishedEvent(stepName: 'execution'));
},
expect: () => [
isA<ChatState>().having(
(s) => s.currentStage,
'currentStage',
AgentStage.execution,
),
isA<ChatState>().having((s) => s.currentStage, 'currentStage', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runError sets error message',
build: () => chatBloc,
@@ -325,5 +353,58 @@ void main() {
),
],
);
blocTest<ChatBloc, ChatState>(
'state snapshot user message keeps attachments',
build: () => chatBloc,
act: (bloc) {
service.onEvent(
StateSnapshotEvent(
snapshot: {
'scope': 'history_day',
'messages': [
{
'id': 'u1',
'role': 'user',
'content': '请分析这张图',
'attachments': [
{'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'},
],
},
],
},
),
);
},
expect: () => [
isA<ChatState>().having(
(s) {
final item = s.items.first;
return item is TextMessageItem && item.attachments.length == 1;
},
'user attachment count',
true,
),
],
);
test(
'loadAttachmentPreview deduplicates in-flight and caches result',
() async {
final mock = service as MockAgUiService;
final results = await Future.wait<Uint8List?>([
chatBloc.loadAttachmentPreview('/api/preview/1'),
chatBloc.loadAttachmentPreview('/api/preview/1'),
]);
final secondRound = await chatBloc.loadAttachmentPreview(
'/api/preview/1',
);
expect(results.first, isNotNull);
expect(results.last, isNotNull);
expect(secondRound, isNotNull);
expect(mock.previewCalls, 1);
},
);
});
}