Files
qzl 19aa33a609 fix: correct test failures and error propagation
- Add CacheScope provider in UserProfileCacheRepository tests
- Remove catch blocks that swallowed errors in _loadHistory/_loadMoreHistory
- Errors now properly propagate to switchUser() caller
2026-04-01 15:11:49 +08:00

174 lines
4.9 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 {
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,
);
}
}