328 lines
9.3 KiB
Dart
328 lines
9.3 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:social_app/core/chat/chat_api.dart';
|
|
import 'package:social_app/core/logging/logger.dart';
|
|
import 'package:social_app/core/chat/agent_stage.dart';
|
|
import 'package:social_app/core/chat/ag_ui_event.dart';
|
|
import 'package:social_app/core/chat/ag_ui_service.dart';
|
|
import 'package:social_app/core/chat/chat_list_item.dart';
|
|
import 'package:social_app/core/chat/chat_orchestrator.dart';
|
|
import 'package:social_app/core/chat/chat_history_repository.dart';
|
|
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
|
|
import 'package:social_app/core/analytics/tracker.dart';
|
|
import 'package:social_app/core/l10n/l10n.dart';
|
|
import 'chat_bloc_recovery_utils.dart';
|
|
|
|
part 'chat_bloc_events.dart';
|
|
part 'chat_bloc_send.dart';
|
|
part 'chat_bloc_history.dart';
|
|
part 'chat_bloc_attachments.dart';
|
|
|
|
typedef ChatCompletedCallback =
|
|
void Function({
|
|
required String conversationId,
|
|
required int messageCount,
|
|
required int responseTimeMs,
|
|
});
|
|
|
|
class ChatState implements ChatOrchestratorState {
|
|
@override
|
|
final List<ChatListItem> items;
|
|
@override
|
|
final bool isSending;
|
|
@override
|
|
final bool isWaitingFirstToken;
|
|
@override
|
|
final bool isStreaming;
|
|
@override
|
|
final bool isCancelling;
|
|
@override
|
|
final bool isLoadingHistory;
|
|
@override
|
|
final String? currentMessageId;
|
|
@override
|
|
final String? error;
|
|
@override
|
|
final DateTime? oldestLoadedDate;
|
|
@override
|
|
final bool hasEarlierHistory;
|
|
@override
|
|
final AgentStage? currentStage;
|
|
final bool hasSeenStep;
|
|
|
|
const ChatState({
|
|
this.items = const [],
|
|
this.isSending = false,
|
|
this.isWaitingFirstToken = false,
|
|
this.isStreaming = false,
|
|
this.isCancelling = false,
|
|
this.isLoadingHistory = false,
|
|
this.currentMessageId,
|
|
this.error,
|
|
this.oldestLoadedDate,
|
|
this.hasEarlierHistory = false,
|
|
this.currentStage,
|
|
this.hasSeenStep = false,
|
|
});
|
|
|
|
@override
|
|
bool get isLoading =>
|
|
isSending ||
|
|
isWaitingFirstToken ||
|
|
isStreaming ||
|
|
isCancelling ||
|
|
isLoadingHistory;
|
|
|
|
static const _unset = Object();
|
|
|
|
ChatState copyWith({
|
|
List<ChatListItem>? items,
|
|
bool? isSending,
|
|
bool? isWaitingFirstToken,
|
|
bool? isStreaming,
|
|
bool? isCancelling,
|
|
bool? isLoadingHistory,
|
|
Object? currentMessageId = _unset,
|
|
Object? error = _unset,
|
|
Object? oldestLoadedDate = _unset,
|
|
bool? hasEarlierHistory,
|
|
Object? currentStage = _unset,
|
|
bool? hasSeenStep,
|
|
}) {
|
|
return ChatState(
|
|
items: items ?? this.items,
|
|
isSending: isSending ?? this.isSending,
|
|
isWaitingFirstToken: isWaitingFirstToken ?? this.isWaitingFirstToken,
|
|
isStreaming: isStreaming ?? this.isStreaming,
|
|
isCancelling: isCancelling ?? this.isCancelling,
|
|
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
|
|
currentMessageId: currentMessageId == _unset
|
|
? this.currentMessageId
|
|
: currentMessageId as String?,
|
|
error: error == _unset ? this.error : error as String?,
|
|
oldestLoadedDate: oldestLoadedDate == _unset
|
|
? this.oldestLoadedDate
|
|
: oldestLoadedDate as DateTime?,
|
|
hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory,
|
|
currentStage: currentStage == _unset
|
|
? this.currentStage
|
|
: currentStage as AgentStage?,
|
|
hasSeenStep: hasSeenStep ?? this.hasSeenStep,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|
final Logger _logger = getLogger('features.chat.bloc');
|
|
|
|
ChatBloc({
|
|
AgUiService? service,
|
|
required ChatApi chatApi,
|
|
ChatHistoryRepository? historyRepository,
|
|
Future<void> Function()? onCalendarMutated,
|
|
ChatCompletedCallback? onChatCompleted,
|
|
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
|
Duration recoveryTimeout = const Duration(seconds: 20),
|
|
}) : _service =
|
|
service ??
|
|
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
|
_onCalendarMutated = onCalendarMutated,
|
|
_onChatCompleted = onChatCompleted,
|
|
_recoveryPollInterval = recoveryPollInterval,
|
|
_recoveryTimeout = recoveryTimeout,
|
|
super(const ChatState()) {
|
|
_service.onEvent = _handleEvent;
|
|
}
|
|
|
|
final AgUiService _service;
|
|
final Future<void> Function()? _onCalendarMutated;
|
|
final ChatCompletedCallback? _onChatCompleted;
|
|
final Duration _recoveryPollInterval;
|
|
final Duration _recoveryTimeout;
|
|
String? _activeUserId;
|
|
DateTime? _activeRunStartedAt;
|
|
DateTime? _activeRunFirstResponseAt;
|
|
String? _activeRunId;
|
|
String? _activeThreadId;
|
|
int _sessionEpoch = 0;
|
|
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
|
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
|
<String, Future<Uint8List?>>{};
|
|
|
|
/// Common state reset for run completion (success/error/cancel)
|
|
ChatState _resetRunState({String? error, String? currentMessageId}) {
|
|
return state.copyWith(
|
|
isSending: false,
|
|
isWaitingFirstToken: false,
|
|
isStreaming: false,
|
|
isCancelling: false,
|
|
currentMessageId: currentMessageId,
|
|
error: error,
|
|
currentStage: null,
|
|
hasSeenStep: false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> sendMessage(String content, {List<XFile>? images}) {
|
|
return _sendMessage(content, images: images);
|
|
}
|
|
|
|
@override
|
|
Future<void> loadHistory() {
|
|
return _loadHistory();
|
|
}
|
|
|
|
@override
|
|
Future<void> loadMoreHistory() {
|
|
return _loadMoreHistory();
|
|
}
|
|
|
|
@override
|
|
Future<String> transcribeAudioFile(String filePath) {
|
|
return _service.transcribeAudio(filePath);
|
|
}
|
|
|
|
@override
|
|
Future<bool> cancelCurrentRun() async {
|
|
if (!(state.isWaitingFirstToken ||
|
|
state.isStreaming ||
|
|
state.isCancelling)) {
|
|
return false;
|
|
}
|
|
emit(state.copyWith(isCancelling: true, error: null));
|
|
try {
|
|
await _service.cancelCurrentRun();
|
|
emit(
|
|
state.copyWith(
|
|
isSending: false,
|
|
isWaitingFirstToken: false,
|
|
isStreaming: false,
|
|
isCancelling: false,
|
|
currentMessageId: null,
|
|
),
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
emit(state.copyWith(isCancelling: false, error: error.toString()));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void clearError() {
|
|
emit(state.copyWith(error: null));
|
|
}
|
|
|
|
Future<Uint8List?> loadAttachmentPreview(String previewPath) {
|
|
return _loadAttachmentPreview(previewPath);
|
|
}
|
|
|
|
Future<void> switchUser(String? userId) async {
|
|
final normalizedUserId = userId?.trim();
|
|
if (_activeUserId == normalizedUserId) {
|
|
return;
|
|
}
|
|
|
|
final epoch = ++_sessionEpoch;
|
|
_activeUserId = normalizedUserId;
|
|
try {
|
|
await _service.setUserContext(normalizedUserId);
|
|
} catch (e, stackTrace) {
|
|
_logger.error(
|
|
message: 'Failed to set user context',
|
|
error: e,
|
|
stackTrace: stackTrace,
|
|
extra: {'user_id': normalizedUserId},
|
|
);
|
|
}
|
|
if (epoch != _sessionEpoch) {
|
|
return;
|
|
}
|
|
_attachmentPreviewCache.clear();
|
|
_attachmentPreviewInflight.clear();
|
|
emit(const ChatState());
|
|
if (normalizedUserId != null && epoch == _sessionEpoch) {
|
|
try {
|
|
await _loadHistory();
|
|
} catch (error) {
|
|
emit(state.copyWith(error: error.toString()));
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) {
|
|
final name = event.toolName.trim().toLowerCase();
|
|
final status = event.status.trim().toLowerCase();
|
|
if (name != 'calendar_write') {
|
|
return false;
|
|
}
|
|
return status == 'success' || status == 'partial';
|
|
}
|
|
|
|
Future<void> _refreshCalendarAfterToolMutation() async {
|
|
final callback = _onCalendarMutated;
|
|
if (callback == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await callback();
|
|
} catch (error) {
|
|
emit(state.copyWith(error: error.toString()));
|
|
}
|
|
}
|
|
|
|
void _recordRunStarted({required String runId, required String threadId}) {
|
|
_activeRunStartedAt = DateTime.now();
|
|
_activeRunFirstResponseAt = null;
|
|
_activeRunId = runId;
|
|
_activeThreadId = threadId;
|
|
}
|
|
|
|
void _recordRunFirstResponse() {
|
|
_activeRunFirstResponseAt ??= DateTime.now();
|
|
}
|
|
|
|
void _trackChatCompleted() {
|
|
final startedAt = _activeRunStartedAt;
|
|
if (startedAt == null) {
|
|
return;
|
|
}
|
|
final firstResponseAt = _activeRunFirstResponseAt ?? DateTime.now();
|
|
final responseTimeMs = firstResponseAt.difference(startedAt).inMilliseconds;
|
|
final threadId = _activeThreadId?.trim();
|
|
final runId = _activeRunId?.trim();
|
|
final conversationId = (threadId != null && threadId.isNotEmpty)
|
|
? threadId
|
|
: runId;
|
|
if (conversationId == null || conversationId.isEmpty) {
|
|
return;
|
|
}
|
|
final onChatCompleted = _onChatCompleted;
|
|
if (onChatCompleted != null) {
|
|
onChatCompleted(
|
|
conversationId: conversationId,
|
|
messageCount: 1,
|
|
responseTimeMs: responseTimeMs,
|
|
);
|
|
return;
|
|
}
|
|
AnalyticsTracker.instance.trackAgentChatCompleted(
|
|
conversationId: conversationId,
|
|
scenario: 'assistant',
|
|
messageCount: 1,
|
|
responseTimeMs: responseTimeMs,
|
|
);
|
|
}
|
|
|
|
void _clearRunMetrics() {
|
|
_activeRunStartedAt = null;
|
|
_activeRunFirstResponseAt = null;
|
|
_activeRunId = null;
|
|
_activeThreadId = null;
|
|
}
|
|
}
|