feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -50,7 +50,6 @@ class ApiClient implements IApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
try {
|
||||
return await _dio.get<T>(path, options: options);
|
||||
|
||||
@@ -40,7 +40,11 @@ class MockApiClient implements IApiClient {
|
||||
_handlers[key] = handler;
|
||||
}
|
||||
|
||||
void registerPatternHandler(RegExp pattern, String method, MockHandler handler) {
|
||||
void registerPatternHandler(
|
||||
RegExp pattern,
|
||||
String method,
|
||||
MockHandler handler,
|
||||
) {
|
||||
_patternHandlers.add(
|
||||
_PatternRoute(
|
||||
pattern: pattern,
|
||||
@@ -96,11 +100,7 @@ class MockApiClient implements IApiClient {
|
||||
final direct = _handlers[key];
|
||||
if (direct != null) {
|
||||
final response = direct(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: 'SSE',
|
||||
headers: headers,
|
||||
),
|
||||
MockRequest(path: path, method: 'SSE', headers: headers),
|
||||
);
|
||||
if (response is Stream<String>) {
|
||||
return response;
|
||||
@@ -118,11 +118,7 @@ class MockApiClient implements IApiClient {
|
||||
continue;
|
||||
}
|
||||
final response = route.handler(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: 'SSE',
|
||||
headers: headers,
|
||||
),
|
||||
MockRequest(path: path, method: 'SSE', headers: headers),
|
||||
);
|
||||
if (response is Stream<String>) {
|
||||
return response;
|
||||
@@ -147,12 +143,7 @@ class MockApiClient implements IApiClient {
|
||||
|
||||
if (handler != null) {
|
||||
final response = handler(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: method,
|
||||
data: data,
|
||||
options: options,
|
||||
),
|
||||
MockRequest(path: path, method: method, data: data, options: options),
|
||||
);
|
||||
if (response is Response) {
|
||||
return response as Response<T>;
|
||||
|
||||
@@ -9,6 +9,8 @@ class AgUiEventTypeWire {
|
||||
static const runStarted = 'RUN_STARTED';
|
||||
static const runFinished = 'RUN_FINISHED';
|
||||
static const runError = 'RUN_ERROR';
|
||||
static const stepStarted = 'STEP_STARTED';
|
||||
static const stepFinished = 'STEP_FINISHED';
|
||||
static const textMessageStart = 'TEXT_MESSAGE_START';
|
||||
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
|
||||
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
||||
@@ -25,6 +27,8 @@ enum AgUiEventType {
|
||||
runStarted,
|
||||
runFinished,
|
||||
runError,
|
||||
stepStarted,
|
||||
stepFinished,
|
||||
textMessageStart,
|
||||
textMessageContent,
|
||||
textMessageEnd,
|
||||
@@ -43,6 +47,8 @@ const _wireToTypeMap = {
|
||||
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
|
||||
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
|
||||
AgUiEventTypeWire.runError: AgUiEventType.runError,
|
||||
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
|
||||
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
|
||||
AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart,
|
||||
AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent,
|
||||
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
|
||||
@@ -60,6 +66,8 @@ const _typeToWireMap = {
|
||||
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
|
||||
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
|
||||
AgUiEventType.runError: AgUiEventTypeWire.runError,
|
||||
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
|
||||
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
|
||||
AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart,
|
||||
AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent,
|
||||
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
|
||||
@@ -83,6 +91,8 @@ final _typeToFactory = {
|
||||
AgUiEventType.runStarted: RunStartedEvent.fromJson,
|
||||
AgUiEventType.runFinished: RunFinishedEvent.fromJson,
|
||||
AgUiEventType.runError: RunErrorEvent.fromJson,
|
||||
AgUiEventType.stepStarted: StepStartedEvent.fromJson,
|
||||
AgUiEventType.stepFinished: StepFinishedEvent.fromJson,
|
||||
AgUiEventType.textMessageStart: TextMessageStartEvent.fromJson,
|
||||
AgUiEventType.textMessageContent: TextMessageContentEvent.fromJson,
|
||||
AgUiEventType.textMessageEnd: TextMessageEndEvent.fromJson,
|
||||
@@ -170,6 +180,34 @@ class RunErrorEvent extends AgUiEvent {
|
||||
Map<String, dynamic> toJson() => _$RunErrorEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StepStartedEvent extends AgUiEvent {
|
||||
final String stepName;
|
||||
|
||||
StepStartedEvent({required this.stepName})
|
||||
: super(type: AgUiEventType.stepStarted);
|
||||
|
||||
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$StepStartedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$StepStartedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StepFinishedEvent extends AgUiEvent {
|
||||
final String stepName;
|
||||
|
||||
StepFinishedEvent({required this.stepName})
|
||||
: super(type: AgUiEventType.stepFinished);
|
||||
|
||||
factory StepFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$StepFinishedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$StepFinishedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageStartEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
@@ -310,10 +348,33 @@ class ToolCallResultEvent extends AgUiEvent {
|
||||
|
||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) {
|
||||
final rawContent = json['content'];
|
||||
final content = rawContent is String ? rawContent : '';
|
||||
final hasStructuredFields =
|
||||
json['ui'] != null || json['result'] != null || json['error'] != null;
|
||||
final content = switch (rawContent) {
|
||||
String value when value.trim().startsWith('{') => value,
|
||||
String value when value.trim().startsWith('[') => value,
|
||||
String value when hasStructuredFields => jsonEncode({
|
||||
'toolName': json['toolName'],
|
||||
'result': json['result'],
|
||||
'error': json['error'],
|
||||
'ui': json['ui'],
|
||||
'content': value,
|
||||
}),
|
||||
String value => value,
|
||||
_ => jsonEncode({
|
||||
'toolName': json['toolName'],
|
||||
'result': json['result'],
|
||||
'error': json['error'],
|
||||
'ui': json['ui'],
|
||||
'content': json['content'],
|
||||
}),
|
||||
};
|
||||
final toolCallId =
|
||||
json['toolCallId'] as String? ?? json['callId'] as String? ?? '';
|
||||
final messageId = json['messageId'] as String? ?? 'tool-result-$toolCallId';
|
||||
return ToolCallResultEvent(
|
||||
messageId: json['messageId'] as String,
|
||||
toolCallId: json['toolCallId'] as String,
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
content: content,
|
||||
);
|
||||
}
|
||||
@@ -388,6 +449,7 @@ class SnapshotMessage {
|
||||
final String? toolCallId;
|
||||
final UiCard? ui;
|
||||
final DateTime? timestamp;
|
||||
final List<Map<String, dynamic>>? attachments;
|
||||
|
||||
SnapshotMessage({
|
||||
required this.id,
|
||||
@@ -396,6 +458,7 @@ class SnapshotMessage {
|
||||
this.toolCallId,
|
||||
this.ui,
|
||||
this.timestamp,
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
factory SnapshotMessage.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -14,6 +14,8 @@ const _$AgUiEventTypeEnumMap = {
|
||||
AgUiEventType.runStarted: 'runStarted',
|
||||
AgUiEventType.runFinished: 'runFinished',
|
||||
AgUiEventType.runError: 'runError',
|
||||
AgUiEventType.stepStarted: 'stepStarted',
|
||||
AgUiEventType.stepFinished: 'stepFinished',
|
||||
AgUiEventType.textMessageStart: 'textMessageStart',
|
||||
AgUiEventType.textMessageContent: 'textMessageContent',
|
||||
AgUiEventType.textMessageEnd: 'textMessageEnd',
|
||||
@@ -53,6 +55,18 @@ RunErrorEvent _$RunErrorEventFromJson(Map<String, dynamic> json) =>
|
||||
Map<String, dynamic> _$RunErrorEventToJson(RunErrorEvent instance) =>
|
||||
<String, dynamic>{'message': instance.message, 'code': instance.code};
|
||||
|
||||
StepStartedEvent _$StepStartedEventFromJson(Map<String, dynamic> json) =>
|
||||
StepStartedEvent(stepName: json['stepName'] as String);
|
||||
|
||||
Map<String, dynamic> _$StepStartedEventToJson(StepStartedEvent instance) =>
|
||||
<String, dynamic>{'stepName': instance.stepName};
|
||||
|
||||
StepFinishedEvent _$StepFinishedEventFromJson(Map<String, dynamic> json) =>
|
||||
StepFinishedEvent(stepName: json['stepName'] as String);
|
||||
|
||||
Map<String, dynamic> _$StepFinishedEventToJson(StepFinishedEvent instance) =>
|
||||
<String, dynamic>{'stepName': instance.stepName};
|
||||
|
||||
TextMessageStartEvent _$TextMessageStartEventFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => TextMessageStartEvent(
|
||||
@@ -170,6 +184,9 @@ SnapshotMessage _$SnapshotMessageFromJson(Map<String, dynamic> json) =>
|
||||
timestamp: json['timestamp'] == null
|
||||
? null
|
||||
: DateTime.parse(json['timestamp'] as String),
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
|
||||
@@ -180,4 +197,5 @@ Map<String, dynamic> _$SnapshotMessageToJson(SnapshotMessage instance) =>
|
||||
'toolCallId': instance.toolCallId,
|
||||
'ui': instance.ui,
|
||||
'timestamp': instance.timestamp?.toIso8601String(),
|
||||
'attachments': instance.attachments,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ class TextMessageItem extends ChatListItem {
|
||||
@override
|
||||
final MessageSender sender;
|
||||
final bool isStreaming;
|
||||
final List<Map<String, dynamic>> attachments;
|
||||
|
||||
TextMessageItem({
|
||||
required this.id,
|
||||
@@ -29,6 +30,7 @@ class TextMessageItem extends ChatListItem {
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
this.isStreaming = false,
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,12 +42,14 @@ class TextMessageItem extends ChatListItem {
|
||||
DateTime? timestamp,
|
||||
MessageSender? sender,
|
||||
bool? isStreaming,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
}) => TextMessageItem(
|
||||
id: id ?? this.id,
|
||||
content: content ?? this.content,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
sender: sender ?? this.sender,
|
||||
isStreaming: isStreaming ?? this.isStreaming,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -80,6 +81,18 @@ class AgUiService {
|
||||
onEvent(event);
|
||||
}
|
||||
|
||||
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
||||
final response = await _apiClient.get<List<int>>(
|
||||
previewPath,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final payload = response.data;
|
||||
if (payload is List<int>) {
|
||||
return Uint8List.fromList(payload);
|
||||
}
|
||||
throw StateError('Invalid attachment payload');
|
||||
}
|
||||
|
||||
Future<String> transcribeAudio(String filePath) async {
|
||||
final formData = FormData.fromMap({
|
||||
'audio': await MultipartFile.fromFile(
|
||||
@@ -247,22 +260,27 @@ class AgUiService {
|
||||
final runId = _nextId(_runIdPrefix);
|
||||
|
||||
final contentBlocks = <Map<String, dynamic>>[];
|
||||
final attachmentMetadata = <Map<String, dynamic>>[];
|
||||
|
||||
if (content.isNotEmpty) {
|
||||
contentBlocks.add({'type': 'text', 'text': content});
|
||||
}
|
||||
|
||||
if (images != null && images.isNotEmpty) {
|
||||
for (final image in images) {
|
||||
final bytes = await image.readAsBytes();
|
||||
final base64 = base64Encode(bytes);
|
||||
final uploadedAttachments = await _uploadAttachments(
|
||||
threadId: threadId,
|
||||
images: images,
|
||||
);
|
||||
for (final attachment in uploadedAttachments) {
|
||||
contentBlocks.add({
|
||||
'type': 'image',
|
||||
'source': {
|
||||
'type': 'base64',
|
||||
'media_type': 'image/jpeg',
|
||||
'data': base64,
|
||||
},
|
||||
'type': 'binary',
|
||||
'mimeType': attachment['mimeType'],
|
||||
'url': attachment['url'],
|
||||
});
|
||||
attachmentMetadata.add({
|
||||
'bucket': attachment['bucket'],
|
||||
'path': attachment['path'],
|
||||
'mimeType': attachment['mimeType'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -286,10 +304,64 @@ class AgUiService {
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'context': <Map<String, dynamic>>[],
|
||||
'forwardedProps': <String, dynamic>{},
|
||||
'forwardedProps': {
|
||||
if (attachmentMetadata.isNotEmpty) 'attachments': attachmentMetadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _uploadAttachments({
|
||||
required String threadId,
|
||||
required List<XFile> images,
|
||||
}) async {
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
for (final image in images) {
|
||||
final mimeType = image.mimeType ?? 'image/jpeg';
|
||||
final fileBytes = await image.readAsBytes();
|
||||
final formData = FormData.fromMap({
|
||||
'threadId': threadId,
|
||||
'file': MultipartFile.fromBytes(
|
||||
fileBytes,
|
||||
filename: image.name,
|
||||
contentType: DioMediaType.parse(mimeType),
|
||||
),
|
||||
});
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/agent/attachments',
|
||||
data: formData,
|
||||
);
|
||||
final payload = response.data;
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw StateError('Invalid /agent/attachments response');
|
||||
}
|
||||
final attachment = payload['attachment'];
|
||||
if (attachment is! Map<String, dynamic>) {
|
||||
throw StateError('Missing attachment in /agent/attachments response');
|
||||
}
|
||||
final bucket = attachment['bucket'];
|
||||
final path = attachment['path'];
|
||||
final uploadedMime = attachment['mimeType'];
|
||||
final url = attachment['url'];
|
||||
if (bucket is! String ||
|
||||
path is! String ||
|
||||
uploadedMime is! String ||
|
||||
url is! String ||
|
||||
bucket.isEmpty ||
|
||||
path.isEmpty ||
|
||||
uploadedMime.isEmpty ||
|
||||
url.isEmpty) {
|
||||
throw StateError('Invalid attachment reference');
|
||||
}
|
||||
attachments.add({
|
||||
'bucket': bucket,
|
||||
'path': path,
|
||||
'mimeType': uploadedMime,
|
||||
'url': url,
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildTools() {
|
||||
return [
|
||||
{
|
||||
@@ -360,6 +432,11 @@ class AgUiService {
|
||||
'SSE',
|
||||
_handleMockSse,
|
||||
);
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/attachments',
|
||||
'POST',
|
||||
_handleMockUploadAttachment,
|
||||
);
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/transcribe',
|
||||
'POST',
|
||||
@@ -371,6 +448,26 @@ class AgUiService {
|
||||
return {'transcript': '这是模拟语音转写'};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockUploadAttachment(MockRequest request) {
|
||||
final payload = request.data;
|
||||
final threadId = payload is Map<String, dynamic>
|
||||
? (payload['threadId'] as String?)
|
||||
: null;
|
||||
final resolvedThreadId = (threadId != null && threadId.isNotEmpty)
|
||||
? threadId
|
||||
: (_threadId ?? _newUuid());
|
||||
final path =
|
||||
'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png';
|
||||
return {
|
||||
'attachment': {
|
||||
'bucket': 'mock-bucket',
|
||||
'path': path,
|
||||
'mimeType': 'image/png',
|
||||
'url': 'https://mock.local/$path',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
||||
final payload = request.data;
|
||||
final runInput = payload is Map<String, dynamic>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -34,12 +35,15 @@ const _rippleDurationMs = 1200;
|
||||
const _recordingDotSize = 10.0;
|
||||
const _transcribingSpinnerSize = 18.0;
|
||||
const _transcribingStrokeWidth = 2.0;
|
||||
const _attachmentPreviewSize = 88.0;
|
||||
const _attachmentPreviewRadius = 10.0;
|
||||
const _attachmentPreviewGap = 8.0;
|
||||
const _inputActionButtonKey = ValueKey('home_input_action_button');
|
||||
const _inputActionIconKey = ValueKey('home_input_action_icon');
|
||||
|
||||
/// 颜色常量
|
||||
const _chatBgColor = Color(0xFFF8FAFC);
|
||||
const _userBubbleColor = Color(0xFFEAF1FB);
|
||||
const _chatBgColor = AppColors.slate50;
|
||||
const _userBubbleColor = AppColors.blue50;
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final VoiceRecorder? voiceRecorder;
|
||||
@@ -265,7 +269,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showWaitingIndicator) _buildWaitingIndicator(),
|
||||
if (showWaitingIndicator)
|
||||
_buildWaitingIndicator(currentStage: state.currentStage),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -310,12 +315,19 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showWaitingIndicator) _buildWaitingIndicator(),
|
||||
if (showWaitingIndicator)
|
||||
_buildWaitingIndicator(currentStage: state.currentStage),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingIndicator() {
|
||||
Widget _buildWaitingIndicator({required AgentStage? currentStage}) {
|
||||
final label = switch (currentStage) {
|
||||
AgentStage.intent => '意图识别中',
|
||||
AgentStage.execution => '任务执行中',
|
||||
AgentStage.report => '结果总结中',
|
||||
null => '正在思考...',
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_defaultPadding,
|
||||
@@ -325,7 +337,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: const [
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
@@ -336,7 +348,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'正在思考...',
|
||||
label,
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
@@ -413,38 +425,152 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
Widget _buildMessageItem(TextMessageItem item) {
|
||||
final isUser = item.sender == MessageSender.user;
|
||||
return Row(
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
final imageAttachments = _collectRenderableImageAttachments(
|
||||
item.attachments,
|
||||
);
|
||||
final hasRenderableAttachments = imageAttachments.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _messagePaddingH,
|
||||
vertical: _messagePaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? _userBubbleColor : AppColors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(_cornerRadius),
|
||||
topRight: const Radius.circular(_cornerRadius),
|
||||
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
|
||||
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
|
||||
Row(
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _messagePaddingH,
|
||||
vertical: _messagePaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? _userBubbleColor : AppColors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(_cornerRadius),
|
||||
topRight: const Radius.circular(_cornerRadius),
|
||||
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
|
||||
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Text(
|
||||
item.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Text(
|
||||
item.content,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.slate900),
|
||||
if (item.attachments.isNotEmpty && !hasRenderableAttachments) ...[
|
||||
const SizedBox(width: _itemSpacing / 2),
|
||||
_buildAttachmentBadge(item.attachments.length),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hasRenderableAttachments)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _attachmentPreviewGap),
|
||||
child: _buildHistoryAttachmentPreviews(
|
||||
item.attachments,
|
||||
imageAttachments: imageAttachments,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryAttachmentPreviews(
|
||||
List<Map<String, dynamic>> attachments, {
|
||||
List<Map<String, dynamic>>? imageAttachments,
|
||||
}) {
|
||||
final renderableAttachments =
|
||||
imageAttachments ?? _collectRenderableImageAttachments(attachments);
|
||||
if (renderableAttachments.isEmpty) {
|
||||
return _buildAttachmentBadge(attachments.length);
|
||||
}
|
||||
return Wrap(
|
||||
spacing: _attachmentPreviewGap,
|
||||
runSpacing: _attachmentPreviewGap,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _collectRenderableImageAttachments(
|
||||
List<Map<String, dynamic>> attachments,
|
||||
) {
|
||||
return attachments.where(_isRenderableImageAttachment).toList();
|
||||
}
|
||||
|
||||
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
|
||||
final mimeType = attachment['mimeType'];
|
||||
final previewPath = attachment['previewPath'];
|
||||
return mimeType is String &&
|
||||
mimeType.startsWith('image/') &&
|
||||
previewPath is String &&
|
||||
previewPath.isNotEmpty;
|
||||
}
|
||||
|
||||
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
|
||||
final previewPath = attachment['previewPath'];
|
||||
if (previewPath is! String || previewPath.isEmpty) {
|
||||
return _buildAttachmentBadge(1);
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
|
||||
child: Container(
|
||||
width: _attachmentPreviewSize,
|
||||
height: _attachmentPreviewSize,
|
||||
color: AppColors.slate100,
|
||||
child: FutureBuilder<Uint8List?>(
|
||||
future: _chatBloc.loadAttachmentPreview(previewPath),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final data = snapshot.data;
|
||||
if (data == null || data.isEmpty) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Image.memory(data, fit: BoxFit.cover, gaplessPlayback: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentBadge(int count) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.slate200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'图片附件 x$count',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.slate600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolCallItem(ToolCallItem item) {
|
||||
final (statusText, statusColor, statusIcon) = switch (item.status) {
|
||||
ToolCallStatus.pending => (
|
||||
|
||||
Reference in New Issue
Block a user