fix: stabilize chat run lifecycle rendering
This commit is contained in:
@@ -13,6 +13,34 @@ typedef EventCallback = void Function(AgUiEvent event);
|
|||||||
|
|
||||||
const _runIdPrefix = 'run_';
|
const _runIdPrefix = 'run_';
|
||||||
|
|
||||||
|
class UploadedAttachment {
|
||||||
|
const UploadedAttachment({
|
||||||
|
required this.localPath,
|
||||||
|
required this.url,
|
||||||
|
required this.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String localPath;
|
||||||
|
final String url;
|
||||||
|
final String mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendMessageResult {
|
||||||
|
const SendMessageResult({required this.uploadedAttachments});
|
||||||
|
|
||||||
|
final List<UploadedAttachment> uploadedAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RunInputPayload {
|
||||||
|
const _RunInputPayload({
|
||||||
|
required this.input,
|
||||||
|
required this.uploadedAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> input;
|
||||||
|
final List<UploadedAttachment> uploadedAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
class AgUiService {
|
class AgUiService {
|
||||||
final IApiClient _apiClient;
|
final IApiClient _apiClient;
|
||||||
EventCallback onEvent;
|
EventCallback onEvent;
|
||||||
@@ -26,23 +54,40 @@ class AgUiService {
|
|||||||
: onEvent = onEvent ?? ((_) {}),
|
: onEvent = onEvent ?? ((_) {}),
|
||||||
_apiClient = apiClient;
|
_apiClient = apiClient;
|
||||||
|
|
||||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
Future<SendMessageResult> sendMessage(
|
||||||
|
String content, {
|
||||||
|
List<XFile>? images,
|
||||||
|
}) async {
|
||||||
final streamToken = ++_activeStreamToken;
|
final streamToken = ++_activeStreamToken;
|
||||||
final runInput = await _buildRunInput(content: content, images: images);
|
final runInputPayload = await _buildRunInput(
|
||||||
|
content: content,
|
||||||
|
images: images,
|
||||||
|
);
|
||||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
'/api/v1/agent/runs',
|
'/api/v1/agent/runs',
|
||||||
data: runInput,
|
data: runInputPayload.input,
|
||||||
);
|
);
|
||||||
final payload = response.data;
|
final payload = response.data;
|
||||||
if (payload is! Map<String, dynamic>) {
|
if (payload is! Map<String, dynamic>) {
|
||||||
throw StateError('Invalid /agent/runs response');
|
throw StateError('Invalid /agent/runs response');
|
||||||
}
|
}
|
||||||
final threadId = payload['threadId'] as String?;
|
final threadId = payload['threadId'] as String?;
|
||||||
|
final runId = payload['runId'] as String?;
|
||||||
if (threadId == null || threadId.isEmpty) {
|
if (threadId == null || threadId.isEmpty) {
|
||||||
throw StateError('Missing threadId in /agent/runs response');
|
throw StateError('Missing threadId in /agent/runs response');
|
||||||
}
|
}
|
||||||
|
if (runId == null || runId.isEmpty) {
|
||||||
|
throw StateError('Missing runId in /agent/runs response');
|
||||||
|
}
|
||||||
_threadId = threadId;
|
_threadId = threadId;
|
||||||
await _streamEventsFromApi(threadId, streamToken: streamToken);
|
await _streamEventsFromApi(
|
||||||
|
threadId,
|
||||||
|
expectedRunId: runId,
|
||||||
|
streamToken: streamToken,
|
||||||
|
);
|
||||||
|
return SendMessageResult(
|
||||||
|
uploadedAttachments: runInputPayload.uploadedAttachments,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||||
@@ -108,6 +153,7 @@ class AgUiService {
|
|||||||
|
|
||||||
Future<void> _streamEventsFromApi(
|
Future<void> _streamEventsFromApi(
|
||||||
String threadId, {
|
String threadId, {
|
||||||
|
required String expectedRunId,
|
||||||
required int streamToken,
|
required int streamToken,
|
||||||
}) async {
|
}) async {
|
||||||
final lastEventId = _lastEventIdByThread[threadId];
|
final lastEventId = _lastEventIdByThread[threadId];
|
||||||
@@ -122,6 +168,7 @@ class AgUiService {
|
|||||||
|
|
||||||
String? eventType;
|
String? eventType;
|
||||||
String? eventId;
|
String? eventId;
|
||||||
|
var hasBoundExpectedRun = false;
|
||||||
final dataBuffer = StringBuffer();
|
final dataBuffer = StringBuffer();
|
||||||
await for (final line in sseLines) {
|
await for (final line in sseLines) {
|
||||||
if (streamToken != _activeStreamToken) {
|
if (streamToken != _activeStreamToken) {
|
||||||
@@ -131,11 +178,32 @@ class AgUiService {
|
|||||||
if (dataBuffer.isNotEmpty) {
|
if (dataBuffer.isNotEmpty) {
|
||||||
final raw = dataBuffer.toString();
|
final raw = dataBuffer.toString();
|
||||||
dataBuffer.clear();
|
dataBuffer.clear();
|
||||||
|
Map<String, dynamic>? decoded;
|
||||||
|
String? eventRunId;
|
||||||
|
String? eventThreadId;
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final parsed = jsonDecode(raw);
|
||||||
if (decoded is Map<String, dynamic>) {
|
if (parsed is Map<String, dynamic>) {
|
||||||
final event = AgUiEvent.fromJson(decoded);
|
decoded = parsed;
|
||||||
onEvent(event);
|
final runId = parsed['runId'];
|
||||||
|
final thread = parsed['threadId'];
|
||||||
|
eventRunId = runId is String ? runId : null;
|
||||||
|
eventThreadId = thread is String ? thread : null;
|
||||||
|
|
||||||
|
final isRunStarted = eventType == AgUiEventTypeWire.runStarted;
|
||||||
|
final isTargetRun = eventRunId == expectedRunId;
|
||||||
|
if (isRunStarted && isTargetRun) {
|
||||||
|
hasBoundExpectedRun = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isThreadMatched =
|
||||||
|
eventThreadId == null || eventThreadId == threadId;
|
||||||
|
final shouldDispatch =
|
||||||
|
isTargetRun || (hasBoundExpectedRun && isThreadMatched);
|
||||||
|
if (shouldDispatch) {
|
||||||
|
final event = AgUiEvent.fromJson(parsed);
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore malformed SSE payload and keep stream alive.
|
// Ignore malformed SSE payload and keep stream alive.
|
||||||
@@ -144,8 +212,14 @@ class AgUiService {
|
|||||||
if (currentEventId != null && currentEventId.isNotEmpty) {
|
if (currentEventId != null && currentEventId.isNotEmpty) {
|
||||||
_lastEventIdByThread[threadId] = currentEventId;
|
_lastEventIdByThread[threadId] = currentEventId;
|
||||||
}
|
}
|
||||||
if (eventType == AgUiEventTypeWire.runFinished ||
|
final isTerminalEvent =
|
||||||
eventType == AgUiEventTypeWire.runError) {
|
eventType == AgUiEventTypeWire.runFinished ||
|
||||||
|
eventType == AgUiEventTypeWire.runError;
|
||||||
|
final isTargetRun = eventRunId == expectedRunId;
|
||||||
|
final isThreadMatched =
|
||||||
|
eventThreadId == null || eventThreadId == threadId;
|
||||||
|
if (isTerminalEvent &&
|
||||||
|
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +248,7 @@ class AgUiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _buildRunInput({
|
Future<_RunInputPayload> _buildRunInput({
|
||||||
required String content,
|
required String content,
|
||||||
List<XFile>? images,
|
List<XFile>? images,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -187,16 +261,17 @@ class AgUiService {
|
|||||||
contentBlocks.add({'type': 'text', 'text': content});
|
contentBlocks.add({'type': 'text', 'text': content});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var uploadedAttachments = const <UploadedAttachment>[];
|
||||||
if (images != null && images.isNotEmpty) {
|
if (images != null && images.isNotEmpty) {
|
||||||
final uploadedAttachments = await _uploadAttachments(
|
uploadedAttachments = await _uploadAttachments(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
images: images,
|
images: images,
|
||||||
);
|
);
|
||||||
for (final attachment in uploadedAttachments) {
|
for (final attachment in uploadedAttachments) {
|
||||||
contentBlocks.add({
|
contentBlocks.add({
|
||||||
'type': 'binary',
|
'type': 'binary',
|
||||||
'mimeType': attachment['mimeType'],
|
'mimeType': attachment.mimeType,
|
||||||
'url': attachment['url'],
|
'url': attachment.url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,24 +286,27 @@ class AgUiService {
|
|||||||
messageContent = contentBlocks;
|
messageContent = contentBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return _RunInputPayload(
|
||||||
'threadId': threadId,
|
input: {
|
||||||
'runId': runId,
|
'threadId': threadId,
|
||||||
'state': <String, dynamic>{},
|
'runId': runId,
|
||||||
'messages': [
|
'state': <String, dynamic>{},
|
||||||
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
'messages': [
|
||||||
],
|
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
||||||
'tools': <Map<String, dynamic>>[],
|
],
|
||||||
'context': <Map<String, dynamic>>[],
|
'tools': <Map<String, dynamic>>[],
|
||||||
'forwardedProps': <String, dynamic>{},
|
'context': <Map<String, dynamic>>[],
|
||||||
};
|
'forwardedProps': <String, dynamic>{},
|
||||||
|
},
|
||||||
|
uploadedAttachments: uploadedAttachments,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> _uploadAttachments({
|
Future<List<UploadedAttachment>> _uploadAttachments({
|
||||||
required String threadId,
|
required String threadId,
|
||||||
required List<XFile> images,
|
required List<XFile> images,
|
||||||
}) async {
|
}) async {
|
||||||
final attachments = <Map<String, dynamic>>[];
|
final attachments = <UploadedAttachment>[];
|
||||||
for (final image in images) {
|
for (final image in images) {
|
||||||
final mimeType = image.mimeType ?? 'image/jpeg';
|
final mimeType = image.mimeType ?? 'image/jpeg';
|
||||||
final fileBytes = await image.readAsBytes();
|
final fileBytes = await image.readAsBytes();
|
||||||
@@ -266,12 +344,13 @@ class AgUiService {
|
|||||||
url.isEmpty) {
|
url.isEmpty) {
|
||||||
throw StateError('Invalid attachment reference');
|
throw StateError('Invalid attachment reference');
|
||||||
}
|
}
|
||||||
attachments.add({
|
attachments.add(
|
||||||
'bucket': bucket,
|
UploadedAttachment(
|
||||||
'path': path,
|
localPath: image.path,
|
||||||
'mimeType': uploadedMime,
|
url: url,
|
||||||
'url': url,
|
mimeType: uploadedMime,
|
||||||
});
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return attachments;
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,16 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
case AgUiEventType.runFinished:
|
case AgUiEventType.runFinished:
|
||||||
emit(_resetRunState());
|
emit(
|
||||||
|
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
||||||
|
);
|
||||||
case AgUiEventType.runError:
|
case AgUiEventType.runError:
|
||||||
final errorEvent = event as RunErrorEvent;
|
final errorEvent = event as RunErrorEvent;
|
||||||
emit(_resetRunState(error: errorEvent.message));
|
emit(
|
||||||
|
_resetRunState(
|
||||||
|
error: errorEvent.message,
|
||||||
|
).copyWith(items: _markActiveToolCallsFailed(state.items)),
|
||||||
|
);
|
||||||
case AgUiEventType.stepStarted:
|
case AgUiEventType.stepStarted:
|
||||||
_handleStepStarted(event as StepStartedEvent);
|
_handleStepStarted(event as StepStartedEvent);
|
||||||
case AgUiEventType.stepFinished:
|
case AgUiEventType.stepFinished:
|
||||||
@@ -167,9 +173,11 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
|
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final withoutToolCalls = _removeToolCallItems(items);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
items: items,
|
items: withoutToolCalls,
|
||||||
currentMessageId: null,
|
currentMessageId: null,
|
||||||
isWaitingFirstToken: false,
|
isWaitingFirstToken: false,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
@@ -264,13 +272,38 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleToolCallResult(ToolCallResultEvent event) {
|
void _handleToolCallResult(ToolCallResultEvent event) {
|
||||||
final items = state.items.where((item) {
|
final items = state.items.map((item) {
|
||||||
return !(item is ToolCallItem && item.id == event.toolCallId);
|
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||||
|
return item.copyWith(status: ToolCallStatus.completed);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
emit(state.copyWith(items: items));
|
emit(state.copyWith(items: items));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<ChatListItem> _removeToolCallItems(List<ChatListItem> items) {
|
||||||
|
return items.where((item) => item is! ToolCallItem).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChatListItem> _markActiveToolCallsFailed(List<ChatListItem> items) {
|
||||||
|
return items.map((item) {
|
||||||
|
if (item is! ToolCallItem) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item.status == ToolCallStatus.error) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item.status == ToolCallStatus.completed) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return item.copyWith(
|
||||||
|
status: ToolCallStatus.error,
|
||||||
|
errorMessage: '本次运行已失败',
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleToolCallError(ToolCallErrorEvent event) {
|
void _handleToolCallError(ToolCallErrorEvent event) {
|
||||||
final items = state.items.map((item) {
|
final items = state.items.map((item) {
|
||||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||||
@@ -341,16 +374,18 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||||
|
final messageId = 'user-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final attachments = (images ?? const <XFile>[])
|
final attachments = (images ?? const <XFile>[])
|
||||||
.map(
|
.map(
|
||||||
(image) => <String, dynamic>{
|
(image) => <String, dynamic>{
|
||||||
'path': image.path,
|
'path': image.path,
|
||||||
'mimeType': 'image/*',
|
'mimeType': image.mimeType ?? 'image/jpeg',
|
||||||
|
'uploading': true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final userMessage = TextMessageItem(
|
final userMessage = TextMessageItem(
|
||||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
id: messageId,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
sender: MessageSender.user,
|
sender: MessageSender.user,
|
||||||
@@ -367,8 +402,13 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await _service.sendMessage(content, images: images);
|
final sendResult = await _service.sendMessage(content, images: images);
|
||||||
|
_syncUploadedAttachments(
|
||||||
|
messageId: messageId,
|
||||||
|
uploadedAttachments: sendResult.uploadedAttachments,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
_markAttachmentUploadDone(messageId);
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
isSending: false,
|
isSending: false,
|
||||||
@@ -381,6 +421,63 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncUploadedAttachments({
|
||||||
|
required String messageId,
|
||||||
|
required List<UploadedAttachment> uploadedAttachments,
|
||||||
|
}) {
|
||||||
|
if (uploadedAttachments.isEmpty) {
|
||||||
|
_markAttachmentUploadDone(messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final items = state.items.map((item) {
|
||||||
|
if (item is! TextMessageItem || item.id != messageId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
final synced = item.attachments.map((attachment) {
|
||||||
|
final localPath = attachment['path'];
|
||||||
|
if (localPath is! String || localPath.isEmpty) {
|
||||||
|
return <String, dynamic>{...attachment, 'uploading': false};
|
||||||
|
}
|
||||||
|
UploadedAttachment? matched;
|
||||||
|
for (final candidate in uploadedAttachments) {
|
||||||
|
if (candidate.localPath == localPath) {
|
||||||
|
matched = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched == null) {
|
||||||
|
return <String, dynamic>{...attachment, 'uploading': false};
|
||||||
|
}
|
||||||
|
return <String, dynamic>{
|
||||||
|
...attachment,
|
||||||
|
'url': matched.url,
|
||||||
|
'mimeType': matched.mimeType,
|
||||||
|
'uploading': false,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
return item.copyWith(attachments: synced);
|
||||||
|
}).toList();
|
||||||
|
emit(state.copyWith(items: items));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markAttachmentUploadDone(String messageId) {
|
||||||
|
final items = state.items.map((item) {
|
||||||
|
if (item is! TextMessageItem || item.id != messageId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
final done = item.attachments
|
||||||
|
.map(
|
||||||
|
(attachment) => <String, dynamic>{
|
||||||
|
...attachment,
|
||||||
|
'uploading': false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return item.copyWith(attachments: done);
|
||||||
|
}).toList();
|
||||||
|
emit(state.copyWith(items: items));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadHistory() async {
|
Future<void> loadHistory() async {
|
||||||
if (state.isLoadingHistory) return;
|
if (state.isLoadingHistory) return;
|
||||||
emit(state.copyWith(isLoadingHistory: true));
|
emit(state.copyWith(isLoadingHistory: true));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const _attachmentPreviewGap = 8.0;
|
|||||||
const _bottomStackReservedHeight = 116.0;
|
const _bottomStackReservedHeight = 116.0;
|
||||||
const _toolResultWidthFactor = 0.9;
|
const _toolResultWidthFactor = 0.9;
|
||||||
const _pullRefreshMinVisibleMs = 450;
|
const _pullRefreshMinVisibleMs = 450;
|
||||||
|
const _waitingIndicatorReservedHeight = 42.0;
|
||||||
|
|
||||||
const homeConversationStageKey = ValueKey('home_conversation_stage');
|
const homeConversationStageKey = ValueKey('home_conversation_stage');
|
||||||
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
|
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
|
||||||
@@ -64,7 +65,6 @@ const _recordingActiveLabelColor = AppColors.white;
|
|||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final VoiceRecorder? voiceRecorder;
|
final VoiceRecorder? voiceRecorder;
|
||||||
final Future<String> Function(String filePath)? onTranscribeAudio;
|
final Future<String> Function(String filePath)? onTranscribeAudio;
|
||||||
final Future<void> Function(String transcript)? onAutoSendTranscript;
|
|
||||||
final ChatBloc? chatBloc;
|
final ChatBloc? chatBloc;
|
||||||
final bool autoLoadHistory;
|
final bool autoLoadHistory;
|
||||||
final List<XFile> initialSelectedImages;
|
final List<XFile> initialSelectedImages;
|
||||||
@@ -73,7 +73,6 @@ class HomeScreen extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.voiceRecorder,
|
this.voiceRecorder,
|
||||||
this.onTranscribeAudio,
|
this.onTranscribeAudio,
|
||||||
this.onAutoSendTranscript,
|
|
||||||
this.chatBloc,
|
this.chatBloc,
|
||||||
this.autoLoadHistory = true,
|
this.autoLoadHistory = true,
|
||||||
this.initialSelectedImages = const [],
|
this.initialSelectedImages = const [],
|
||||||
@@ -91,7 +90,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
late final VoiceRecorder _voiceRecorder;
|
late final VoiceRecorder _voiceRecorder;
|
||||||
late final InboxApi _inboxApi;
|
late final InboxApi _inboxApi;
|
||||||
late final Future<String> Function(String filePath) _transcribeAudio;
|
late final Future<String> Function(String filePath) _transcribeAudio;
|
||||||
late final Future<void> Function(String transcript) _autoSendTranscript;
|
|
||||||
late final AnimationController _listeningAnimationController;
|
late final AnimationController _listeningAnimationController;
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isHoldToSpeakMode = true;
|
bool _isHoldToSpeakMode = true;
|
||||||
@@ -101,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
bool _isPullRefreshing = false;
|
bool _isPullRefreshing = false;
|
||||||
int _unreadCount = 0;
|
int _unreadCount = 0;
|
||||||
final List<XFile> _selectedImages = [];
|
final List<XFile> _selectedImages = [];
|
||||||
|
int _lastObservedItemCount = 0;
|
||||||
|
bool _lastObservedWaiting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -110,7 +110,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_inboxApi = sl<InboxApi>();
|
_inboxApi = sl<InboxApi>();
|
||||||
_transcribeAudio =
|
_transcribeAudio =
|
||||||
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
|
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
|
||||||
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
|
|
||||||
_listeningAnimationController = AnimationController(
|
_listeningAnimationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||||
@@ -119,6 +118,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
if (widget.autoLoadHistory) {
|
if (widget.autoLoadHistory) {
|
||||||
_chatBloc.loadHistory();
|
_chatBloc.loadHistory();
|
||||||
}
|
}
|
||||||
|
_lastObservedItemCount = _chatBloc.state.items.length;
|
||||||
|
_lastObservedWaiting = _isAgentWaiting(_chatBloc.state);
|
||||||
_loadUnreadCount();
|
_loadUnreadCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
Toast.show(context, state.error!, type: ToastType.error);
|
Toast.show(context, state.error!, type: ToastType.error);
|
||||||
}
|
}
|
||||||
|
final isWaitingNow = _isAgentWaiting(state);
|
||||||
|
final hasItemCountChanged =
|
||||||
|
state.items.length != _lastObservedItemCount;
|
||||||
|
final waitingStateChanged = isWaitingNow != _lastObservedWaiting;
|
||||||
|
if (hasItemCountChanged || waitingStateChanged) {
|
||||||
|
_scheduleAutoScroll(animated: hasItemCountChanged);
|
||||||
|
}
|
||||||
|
_lastObservedItemCount = state.items.length;
|
||||||
|
_lastObservedWaiting = isWaitingNow;
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -190,8 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatArea(BuildContext context, ChatState state) {
|
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||||
final showWaitingIndicator =
|
final showWaitingIndicator = _isAgentWaiting(state);
|
||||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
|
||||||
|
|
||||||
if (state.isLoadingHistory && state.items.isEmpty) {
|
if (state.isLoadingHistory && state.items.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -219,7 +228,12 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(top: AppSpacing.sm),
|
padding: EdgeInsets.only(
|
||||||
|
top: AppSpacing.sm,
|
||||||
|
bottom: showWaitingIndicator
|
||||||
|
? _waitingIndicatorReservedHeight
|
||||||
|
: AppSpacing.none,
|
||||||
|
),
|
||||||
itemCount:
|
itemCount:
|
||||||
state.items.length + (state.hasEarlierHistory ? 1 : 0),
|
state.items.length + (state.hasEarlierHistory ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -391,6 +405,28 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
context.read<ChatBloc>().loadMoreHistory();
|
context.read<ChatBloc>().loadMoreHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isAgentWaiting(ChatState state) {
|
||||||
|
return state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleAutoScroll({required bool animated}) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final maxExtent = _scrollController.position.maxScrollExtent;
|
||||||
|
if (animated) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
maxExtent,
|
||||||
|
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scrollController.jumpTo(maxExtent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildChatItem(ChatListItem item) {
|
Widget _buildChatItem(ChatListItem item) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case ChatItemType.message:
|
case ChatItemType.message:
|
||||||
@@ -482,47 +518,89 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
|
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
|
||||||
|
final path = attachment['path'];
|
||||||
final url = attachment['url'];
|
final url = attachment['url'];
|
||||||
final mimeType = attachment['mimeType'];
|
final mimeType = attachment['mimeType'];
|
||||||
return url is String &&
|
final hasRenderableSource =
|
||||||
url.isNotEmpty &&
|
(url is String && url.isNotEmpty) ||
|
||||||
|
(path is String && path.isNotEmpty);
|
||||||
|
return hasRenderableSource &&
|
||||||
mimeType is String &&
|
mimeType is String &&
|
||||||
mimeType.startsWith('image/');
|
mimeType.startsWith('image/');
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
|
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
|
||||||
|
final path = attachment['path'];
|
||||||
final url = attachment['url'];
|
final url = attachment['url'];
|
||||||
if (url is! String || url.isEmpty) {
|
final isUploading = attachment['uploading'] == true;
|
||||||
|
|
||||||
|
final Widget image;
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
image = Image.network(
|
||||||
|
url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) return child;
|
||||||
|
return const Center(
|
||||||
|
child: AppLoadingIndicator(
|
||||||
|
variant: AppLoadingVariant.inline,
|
||||||
|
size: _transcribingSpinnerSize,
|
||||||
|
strokeWidth: _transcribingStrokeWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.imageOff,
|
||||||
|
size: _iconSize,
|
||||||
|
color: AppColors.slate500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (path is String && path.isNotEmpty) {
|
||||||
|
image = Image.file(
|
||||||
|
File(path),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.imageOff,
|
||||||
|
size: _iconSize,
|
||||||
|
color: AppColors.slate500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
|
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: _attachmentPreviewSize,
|
width: _attachmentPreviewSize,
|
||||||
height: _attachmentPreviewSize,
|
height: _attachmentPreviewSize,
|
||||||
color: AppColors.slate100,
|
color: AppColors.slate100,
|
||||||
child: Image.network(
|
child: Stack(
|
||||||
url,
|
fit: StackFit.expand,
|
||||||
fit: BoxFit.cover,
|
children: [
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
image,
|
||||||
if (loadingProgress == null) return child;
|
if (isUploading)
|
||||||
return const Center(
|
ColoredBox(
|
||||||
child: AppLoadingIndicator(
|
color: AppColors.slate900.withValues(alpha: 0.2),
|
||||||
variant: AppLoadingVariant.inline,
|
child: const Center(
|
||||||
size: _transcribingSpinnerSize,
|
child: AppLoadingIndicator(
|
||||||
strokeWidth: _transcribingStrokeWidth,
|
variant: AppLoadingVariant.inline,
|
||||||
|
size: _transcribingSpinnerSize,
|
||||||
|
strokeWidth: _transcribingStrokeWidth,
|
||||||
|
color: AppColors.white,
|
||||||
|
trackColor: AppColors.slate200,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Center(
|
|
||||||
child: Icon(
|
|
||||||
LucideIcons.imageOff,
|
|
||||||
size: _iconSize,
|
|
||||||
color: AppColors.slate500,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -841,7 +919,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context.read<ChatBloc>().sendMessage(content, images: images);
|
await _chatBloc.sendMessage(content, images: images);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1034,8 +1112,11 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
TextPosition(offset: transcript.length),
|
TextPosition(offset: transcript.length),
|
||||||
);
|
);
|
||||||
if (autoSendAfterTranscribe) {
|
if (autoSendAfterTranscribe) {
|
||||||
_messageController.clear();
|
_messageController.text = normalizedTranscript;
|
||||||
await _autoSendTranscript(normalizedTranscript);
|
_messageController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: normalizedTranscript.length),
|
||||||
|
);
|
||||||
|
await _sendMessage(context);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:social_app/core/api/i_api_client.dart';
|
||||||
|
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||||
|
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||||
|
|
||||||
|
class _FakeApiClient implements IApiClient {
|
||||||
|
_FakeApiClient({required this.sseLines});
|
||||||
|
|
||||||
|
final List<String> sseLines;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> get<T>(String path, {Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Stream<String>> getSseLines(
|
||||||
|
String path, {
|
||||||
|
Map<String, String>? headers,
|
||||||
|
}) async {
|
||||||
|
return Stream<String>.fromIterable(sseLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'taskId': 'task-1',
|
||||||
|
'threadId': 'thread-1',
|
||||||
|
'runId': 'run-new',
|
||||||
|
'created': true,
|
||||||
|
};
|
||||||
|
return Response<T>(
|
||||||
|
requestOptions: RequestOptions(path: path),
|
||||||
|
data: payload as T,
|
||||||
|
statusCode: 202,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _buildSseEvent({
|
||||||
|
required String id,
|
||||||
|
required String type,
|
||||||
|
required String payload,
|
||||||
|
}) {
|
||||||
|
return <String>['id: $id', 'event: $type', 'data: $payload', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'sendMessage ignores stale run events and waits for expected run',
|
||||||
|
() async {
|
||||||
|
final oldRunLines = _buildSseEvent(
|
||||||
|
id: '1',
|
||||||
|
type: AgUiEventTypeWire.runStarted,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-old"}',
|
||||||
|
);
|
||||||
|
final oldFinishedLines = _buildSseEvent(
|
||||||
|
id: '2',
|
||||||
|
type: AgUiEventTypeWire.runFinished,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-old"}',
|
||||||
|
);
|
||||||
|
final newRunLines = _buildSseEvent(
|
||||||
|
id: '3',
|
||||||
|
type: AgUiEventTypeWire.runStarted,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||||
|
);
|
||||||
|
final newFinishedLines = _buildSseEvent(
|
||||||
|
id: '4',
|
||||||
|
type: AgUiEventTypeWire.runFinished,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final service = AgUiService(
|
||||||
|
apiClient: _FakeApiClient(
|
||||||
|
sseLines: <String>[
|
||||||
|
...oldRunLines,
|
||||||
|
...oldFinishedLines,
|
||||||
|
...newRunLines,
|
||||||
|
...newFinishedLines,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final events = <AgUiEvent>[];
|
||||||
|
service.onEvent = events.add;
|
||||||
|
|
||||||
|
await service.sendMessage('hello');
|
||||||
|
|
||||||
|
expect(events, hasLength(2));
|
||||||
|
expect(events.first, isA<RunStartedEvent>());
|
||||||
|
expect((events.first as RunStartedEvent).runId, 'run-new');
|
||||||
|
expect(events.last, isA<RunFinishedEvent>());
|
||||||
|
expect((events.last as RunFinishedEvent).runId, 'run-new');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'sendMessage accepts in-run terminal event without runId after binding',
|
||||||
|
() async {
|
||||||
|
final newRunLines = _buildSseEvent(
|
||||||
|
id: '11',
|
||||||
|
type: AgUiEventTypeWire.runStarted,
|
||||||
|
payload:
|
||||||
|
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||||
|
);
|
||||||
|
final noRunIdTextLines = _buildSseEvent(
|
||||||
|
id: '12',
|
||||||
|
type: AgUiEventTypeWire.textMessageEnd,
|
||||||
|
payload:
|
||||||
|
'{"type":"TEXT_MESSAGE_END","threadId":"thread-1","messageId":"m1","answer":"ok","role":"assistant","status":"success"}',
|
||||||
|
);
|
||||||
|
final noRunIdFinishedLines = _buildSseEvent(
|
||||||
|
id: '13',
|
||||||
|
type: AgUiEventTypeWire.runFinished,
|
||||||
|
payload: '{"type":"RUN_FINISHED","threadId":"thread-1"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final service = AgUiService(
|
||||||
|
apiClient: _FakeApiClient(
|
||||||
|
sseLines: <String>[
|
||||||
|
...newRunLines,
|
||||||
|
...noRunIdTextLines,
|
||||||
|
...noRunIdFinishedLines,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final events = <AgUiEvent>[];
|
||||||
|
service.onEvent = events.add;
|
||||||
|
|
||||||
|
await service.sendMessage('hello');
|
||||||
|
|
||||||
|
expect(events, hasLength(3));
|
||||||
|
expect(events[0], isA<RunStartedEvent>());
|
||||||
|
expect(events[1], isA<TextMessageEndEvent>());
|
||||||
|
expect(events[2], isA<RunFinishedEvent>());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:social_app/core/api/i_api_client.dart';
|
||||||
|
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||||
|
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||||
|
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||||
|
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||||
|
|
||||||
|
class _NoopApiClient implements IApiClient {
|
||||||
|
@override
|
||||||
|
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> get<T>(String path, {Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Stream<String>> getSseLines(
|
||||||
|
String path, {
|
||||||
|
Map<String, String>? headers,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<T>> post<T>(String path, {data, Options? options}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeAgUiService extends AgUiService {
|
||||||
|
_FakeAgUiService() : super(apiClient: _NoopApiClient());
|
||||||
|
|
||||||
|
Completer<SendMessageResult>? pendingResult;
|
||||||
|
Object? nextError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SendMessageResult> sendMessage(
|
||||||
|
String content, {
|
||||||
|
List<XFile>? images,
|
||||||
|
}) async {
|
||||||
|
final error = nextError;
|
||||||
|
if (error != null) {
|
||||||
|
nextError = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
final pending = pendingResult;
|
||||||
|
if (pending != null) {
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
return const SendMessageResult(uploadedAttachments: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitEvent(AgUiEvent event) {
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ChatBloc attachment sync', () {
|
||||||
|
late _FakeAgUiService service;
|
||||||
|
late ChatBloc bloc;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
service = _FakeAgUiService();
|
||||||
|
bloc = ChatBloc(service: service, apiClient: _NoopApiClient());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optimistic local image is replaced with uploaded url', () async {
|
||||||
|
final completer = Completer<SendMessageResult>();
|
||||||
|
service.pendingResult = completer;
|
||||||
|
|
||||||
|
final sendFuture = bloc.sendMessage(
|
||||||
|
'hello',
|
||||||
|
images: [
|
||||||
|
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final optimistic = bloc.state.items.last as TextMessageItem;
|
||||||
|
expect(optimistic.attachments, hasLength(1));
|
||||||
|
expect(optimistic.attachments.first['path'], '/tmp/local.jpg');
|
||||||
|
expect(optimistic.attachments.first['uploading'], isTrue);
|
||||||
|
|
||||||
|
completer.complete(
|
||||||
|
const SendMessageResult(
|
||||||
|
uploadedAttachments: [
|
||||||
|
UploadedAttachment(
|
||||||
|
localPath: '/tmp/local.jpg',
|
||||||
|
url: 'https://cdn.example.com/a.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await sendFuture;
|
||||||
|
|
||||||
|
final synced = bloc.state.items.last as TextMessageItem;
|
||||||
|
expect(synced.attachments.first['url'], 'https://cdn.example.com/a.jpg');
|
||||||
|
expect(synced.attachments.first['uploading'], isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'upload failure clears uploading state to avoid endless spinner',
|
||||||
|
() async {
|
||||||
|
service.nextError = StateError('upload failed');
|
||||||
|
|
||||||
|
await bloc.sendMessage(
|
||||||
|
'hello',
|
||||||
|
images: [
|
||||||
|
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final failed = bloc.state.items.last as TextMessageItem;
|
||||||
|
expect(failed.attachments.first['uploading'], isFalse);
|
||||||
|
expect(bloc.state.error, contains('upload failed'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('tool call stays visible until assistant final output', () {
|
||||||
|
service.emitEvent(
|
||||||
|
ToolCallStartEvent(toolCallId: 'tool-1', toolCallName: 'ocr_image'),
|
||||||
|
);
|
||||||
|
var toolItem = bloc.state.items.last as ToolCallItem;
|
||||||
|
expect(toolItem.status, ToolCallStatus.pending);
|
||||||
|
|
||||||
|
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-1'));
|
||||||
|
toolItem = bloc.state.items.last as ToolCallItem;
|
||||||
|
expect(toolItem.status, ToolCallStatus.executing);
|
||||||
|
|
||||||
|
service.emitEvent(
|
||||||
|
ToolCallResultEvent(
|
||||||
|
messageId: 'tool-msg-1',
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
toolName: 'ocr_image',
|
||||||
|
resultSummary: 'done',
|
||||||
|
status: 'success',
|
||||||
|
uiSchema: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toolItem = bloc.state.items.last as ToolCallItem;
|
||||||
|
expect(toolItem.status, ToolCallStatus.completed);
|
||||||
|
|
||||||
|
service.emitEvent(
|
||||||
|
TextMessageEndEvent(
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
answer: '识别完成',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'success',
|
||||||
|
uiSchema: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bloc.state.items.whereType<ToolCallItem>(), isEmpty);
|
||||||
|
expect(bloc.state.items.whereType<TextMessageItem>().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run error keeps tool card and marks it failed', () {
|
||||||
|
service.emitEvent(
|
||||||
|
ToolCallStartEvent(toolCallId: 'tool-err', toolCallName: 'ocr_image'),
|
||||||
|
);
|
||||||
|
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-err'));
|
||||||
|
|
||||||
|
service.emitEvent(RunErrorEvent(message: 'runtime execution failed'));
|
||||||
|
|
||||||
|
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||||
|
expect(toolItem.status, ToolCallStatus.error);
|
||||||
|
expect(toolItem.errorMessage, '本次运行已失败');
|
||||||
|
expect(bloc.state.error, 'runtime execution failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user