// 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 _sendMessage(String content, {List? images}) async { final epoch = _sessionEpoch; final assistantBaselineAtSend = chatBlocLatestAssistantTimestamp( state.items, ); final sendStartedAt = DateTime.now(); final messageId = 'user-${sendStartedAt.millisecondsSinceEpoch}'; final localEchoAttachments = (images ?? const []) .map( (image) => { '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 []).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 _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.delayed( remaining < _recoveryPollInterval ? remaining : _recoveryPollInterval, ); } return false; } catch (_) { return false; } } List _mergeWithHistory( List localItems, List historyMessages, ) { final historyItems = _convertHistoryMessages(historyMessages); return ChatTimelineReconciler.merge( localItems: localItems, remoteItems: historyItems, ); } }