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_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
@@ -10,6 +11,8 @@ import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
import '../../data/services/ag_ui_service.dart';
enum AgentStage { intent, execution, report }
class ChatState {
final List<ChatListItem> items;
final bool isSending;
@@ -21,6 +24,7 @@ class ChatState {
final String? error;
final DateTime? oldestLoadedDate;
final bool hasEarlierHistory;
final AgentStage? currentStage;
const ChatState({
this.items = const [],
@@ -33,6 +37,7 @@ class ChatState {
this.error,
this.oldestLoadedDate,
this.hasEarlierHistory = false,
this.currentStage,
});
bool get isLoading =>
@@ -55,6 +60,7 @@ class ChatState {
Object? error = _unset,
Object? oldestLoadedDate = _unset,
bool? hasEarlierHistory,
Object? currentStage = _unset,
}) {
return ChatState(
items: items ?? this.items,
@@ -71,6 +77,9 @@ class ChatState {
? this.oldestLoadedDate
: oldestLoadedDate as DateTime?,
hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory,
currentStage: currentStage == _unset
? this.currentStage
: currentStage as AgentStage?,
);
}
}
@@ -78,6 +87,9 @@ class ChatState {
class ChatBloc extends Cubit<ChatState> {
final AgUiService _service;
final Map<String, String> _toolCallArgsBuffer = {};
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
@@ -102,6 +114,7 @@ class ChatBloc extends Cubit<ChatState> {
isWaitingFirstToken: true,
isCancelling: false,
error: null,
currentStage: null,
),
);
case AgUiEventType.runFinished:
@@ -112,6 +125,7 @@ class ChatBloc extends Cubit<ChatState> {
isStreaming: false,
isCancelling: false,
currentMessageId: null,
currentStage: null,
),
);
case AgUiEventType.runError:
@@ -124,8 +138,13 @@ class ChatBloc extends Cubit<ChatState> {
isCancelling: false,
currentMessageId: null,
error: errorEvent.message,
currentStage: null,
),
);
case AgUiEventType.stepStarted:
_handleStepStarted(event as StepStartedEvent);
case AgUiEventType.stepFinished:
_handleStepFinished(event as StepFinishedEvent);
case AgUiEventType.textMessageStart:
_handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
@@ -151,6 +170,16 @@ class ChatBloc extends Cubit<ChatState> {
}
}
void _handleStepStarted(StepStartedEvent event) {
emit(state.copyWith(currentStage: _stageFromName(event.stepName)));
}
void _handleStepFinished(StepFinishedEvent event) {
if (state.currentStage == _stageFromName(event.stepName)) {
emit(state.copyWith(currentStage: null));
}
}
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
final newMessage = TextMessageItem(
id: startEvent.messageId,
@@ -327,6 +356,7 @@ class ChatBloc extends Cubit<ChatState> {
content: msg.content ?? '',
timestamp: timestamp,
sender: MessageSender.user,
attachments: msg.attachments ?? const [],
);
case 'assistant':
return TextMessageItem(
@@ -369,11 +399,20 @@ class ChatBloc extends Cubit<ChatState> {
}
Future<void> sendMessage(String content, {List<XFile>? images}) async {
final attachments = (images ?? const <XFile>[])
.map(
(image) => <String, dynamic>{
"path": image.path,
"mimeType": "image/*",
},
)
.toList();
final userMessage = TextMessageItem(
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
content: content,
timestamp: DateTime.now(),
sender: MessageSender.user,
attachments: attachments,
);
emit(
state.copyWith(
@@ -509,7 +548,43 @@ class ChatBloc extends Cubit<ChatState> {
}
}
Future<Uint8List?> loadAttachmentPreview(String previewPath) async {
final cached = _attachmentPreviewCache[previewPath];
if (cached != null) {
return cached;
}
final pending = _attachmentPreviewInflight[previewPath];
if (pending != null) {
return pending;
}
final future = _service
.fetchAttachmentPreview(previewPath)
.then((bytes) {
_attachmentPreviewCache[previewPath] = bytes;
return bytes;
})
.catchError((_) => null)
.whenComplete(() {
_attachmentPreviewInflight.remove(previewPath);
});
_attachmentPreviewInflight[previewPath] = future;
return future;
}
void clearError() {
emit(state.copyWith(error: null));
}
}
AgentStage? _stageFromName(String value) {
switch (value) {
case 'intent':
return AgentStage.intent;
case 'execution':
return AgentStage.execution;
case 'report':
return AgentStage.report;
default:
return null;
}
}