182 lines
5.1 KiB
Dart
182 lines
5.1 KiB
Dart
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
|
|
|
part of 'chat_bloc.dart';
|
|
|
|
extension _ChatBlocSend on ChatBloc {
|
|
Future<void> _sendMessage(String content, {List<XFile>? images}) async {
|
|
_logger.info(
|
|
message: 'Sending chat message',
|
|
extra: {
|
|
'message_length': content.length,
|
|
'attachments_count': images?.length ?? 0,
|
|
},
|
|
);
|
|
|
|
final epoch = _sessionEpoch;
|
|
final assistantBaselineAtSend = chatBlocLatestAssistantTimestamp(
|
|
state.items,
|
|
);
|
|
final sendStartedAt = DateTime.now();
|
|
final messageId = 'user-${sendStartedAt.millisecondsSinceEpoch}';
|
|
final localEchoAttachments = (images ?? const <XFile>[])
|
|
.map(
|
|
(image) => <String, dynamic>{
|
|
'path': image.path,
|
|
'mimeType': image.mimeType ?? 'image/jpeg',
|
|
'uploading': true,
|
|
},
|
|
)
|
|
.toList();
|
|
final localEcho = TextMessageItem(
|
|
id: messageId,
|
|
content: content,
|
|
timestamp: sendStartedAt,
|
|
sender: MessageSender.user,
|
|
isLocalEcho: true,
|
|
attachments: localEchoAttachments,
|
|
);
|
|
|
|
emit(
|
|
state.copyWith(
|
|
items: [...state.items, localEcho],
|
|
isSending: true,
|
|
isWaitingFirstToken: true,
|
|
isStreaming: false,
|
|
isCancelling: false,
|
|
error: null,
|
|
),
|
|
);
|
|
|
|
try {
|
|
final uploadInputs = await Future.wait(
|
|
(images ?? const <XFile>[]).map(
|
|
(image) async => AttachmentUploadInput(
|
|
name: image.name,
|
|
mimeType: image.mimeType ?? 'image/jpeg',
|
|
bytes: await image.readAsBytes(),
|
|
localPath: image.path,
|
|
),
|
|
),
|
|
);
|
|
final sendResult = await _service.sendMessage(
|
|
content,
|
|
attachments: uploadInputs,
|
|
);
|
|
if (epoch != _sessionEpoch) {
|
|
return;
|
|
}
|
|
_logger.info(
|
|
message: 'Chat message sent successfully',
|
|
extra: {'message_id': messageId},
|
|
);
|
|
_syncUploadedAttachments(
|
|
messageId: messageId,
|
|
uploadedAttachments: sendResult.uploadedAttachments,
|
|
);
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Failed to send chat message',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
extra: {'message': content},
|
|
);
|
|
if (epoch != _sessionEpoch) {
|
|
return;
|
|
}
|
|
final sseClosedBeforeTerminal = chatBlocIsSseClosedBeforeTerminalError(
|
|
error,
|
|
);
|
|
var recoveredFromHistory = false;
|
|
if (sseClosedBeforeTerminal) {
|
|
recoveredFromHistory = await _recoverFromAbnormalSseClose(
|
|
epoch: epoch,
|
|
localEchoMessage: localEcho,
|
|
sendStartedAt: sendStartedAt,
|
|
assistantBaselineAtSend: assistantBaselineAtSend,
|
|
);
|
|
}
|
|
if (epoch != _sessionEpoch) {
|
|
return;
|
|
}
|
|
_markAttachmentUploadDone(messageId);
|
|
emit(
|
|
state.copyWith(
|
|
isSending: false,
|
|
isWaitingFirstToken: false,
|
|
isStreaming: false,
|
|
isCancelling: false,
|
|
currentStage: null,
|
|
error: sseClosedBeforeTerminal
|
|
? (recoveredFromHistory
|
|
? null
|
|
: L10n.current.chatSseInterruptedRetry)
|
|
: error.toString(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<bool> _recoverFromAbnormalSseClose({
|
|
required int epoch,
|
|
required TextMessageItem localEchoMessage,
|
|
required DateTime sendStartedAt,
|
|
required DateTime? assistantBaselineAtSend,
|
|
}) async {
|
|
try {
|
|
final deadline = DateTime.now().add(_recoveryTimeout);
|
|
|
|
while (DateTime.now().isBefore(deadline)) {
|
|
final snapshot = await _service.loadHistory(forceRefresh: true);
|
|
if (epoch != _sessionEpoch) {
|
|
return false;
|
|
}
|
|
final merged = _mergeWithHistory(state.items, snapshot.messages);
|
|
emit(
|
|
state.copyWith(
|
|
items: merged,
|
|
oldestLoadedDate: _extractDateFromItems(merged),
|
|
hasEarlierHistory: snapshot.hasMore,
|
|
),
|
|
);
|
|
|
|
final persistedUserTimestamp = chatBlocFindPersistedUserTimestamp(
|
|
merged,
|
|
localEchoMessage,
|
|
sendStartedAt,
|
|
);
|
|
final assistantCaughtUp = chatBlocHasAssistantAfterBaseline(
|
|
merged,
|
|
assistantBaselineAtSend,
|
|
notBefore: persistedUserTimestamp ?? sendStartedAt,
|
|
);
|
|
if (persistedUserTimestamp != null && assistantCaughtUp) {
|
|
return true;
|
|
}
|
|
|
|
final remaining = deadline.difference(DateTime.now());
|
|
if (remaining <= Duration.zero) {
|
|
break;
|
|
}
|
|
await Future<void>.delayed(
|
|
remaining < _recoveryPollInterval ? remaining : _recoveryPollInterval,
|
|
);
|
|
}
|
|
|
|
return false;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
List<ChatListItem> _mergeWithHistory(
|
|
List<ChatListItem> localItems,
|
|
List<HistoryMessage> historyMessages,
|
|
) {
|
|
final historyItems = _convertHistoryMessages(historyMessages);
|
|
return ChatTimelineReconciler.merge(
|
|
localItems: localItems,
|
|
remoteItems: historyItems,
|
|
);
|
|
}
|
|
}
|