import 'dart:typed_data'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/i_api_client.dart'; import '../../data/models/ag_ui_event.dart'; import '../../data/models/chat_list_item.dart'; import '../../data/services/ag_ui_service.dart'; import 'agent_stage.dart'; class ChatState { final List items; final bool isSending; final bool isWaitingFirstToken; final bool isStreaming; final bool isCancelling; final bool isLoadingHistory; final String? currentMessageId; final String? error; final DateTime? oldestLoadedDate; final bool hasEarlierHistory; final AgentStage? currentStage; 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, }); bool get isLoading => isSending || isWaitingFirstToken || isStreaming || isCancelling || isLoadingHistory; static const _unset = Object(); ChatState copyWith({ List? 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, }) { 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?, ); } } class ChatBloc extends Cubit { ChatBloc({AgUiService? service, required IApiClient apiClient}) : _service = service ?? AgUiService(apiClient: apiClient), super(const ChatState()) { _service.onEvent = _handleEvent; } final AgUiService _service; final Map _attachmentPreviewCache = {}; final Map> _attachmentPreviewInflight = >{}; /// 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, ); } void _handleEvent(AgUiEvent event) { switch (event.type) { case AgUiEventType.runStarted: emit( state.copyWith( isSending: false, isWaitingFirstToken: true, isCancelling: false, error: null, currentStage: null, ), ); case AgUiEventType.runFinished: emit(_resetRunState()); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; emit(_resetRunState(error: errorEvent.message)); case AgUiEventType.stepStarted: _handleStepStarted(event as StepStartedEvent); case AgUiEventType.stepFinished: _handleStepFinished(event as StepFinishedEvent); case AgUiEventType.textMessageEnd: _handleTextMessageEnd(event as TextMessageEndEvent); case AgUiEventType.toolCallStart: _handleToolCallStart(event as ToolCallStartEvent); case AgUiEventType.toolCallArgs: _handleToolCallArgs(event as ToolCallArgsEvent); case AgUiEventType.toolCallEnd: _handleToolCallEnd(event as ToolCallEndEvent); case AgUiEventType.toolCallResult: _handleToolCallResult(event as ToolCallResultEvent); case AgUiEventType.toolCallError: _handleToolCallError(event as ToolCallErrorEvent); case AgUiEventType.unknown: break; } } void _handleStepStarted(StepStartedEvent event) { emit(state.copyWith(currentStage: stageFromStepName(event.stepName))); } void _handleStepFinished(StepFinishedEvent event) { if (state.currentStage == stageFromStepName(event.stepName)) { emit(state.copyWith(currentStage: null)); } } void _handleTextMessageEnd(TextMessageEndEvent event) { final timestamp = DateTime.now(); final items = _updateOrAddMessage( state.items, event.messageId, event.answer, timestamp, ); final uiSchema = event.uiSchema; if (uiSchema != null) { _upsertUiSchema(items, event.messageId, uiSchema, timestamp); } emit( state.copyWith( items: items, currentMessageId: null, isWaitingFirstToken: false, isStreaming: false, ), ); } List _updateOrAddMessage( List items, String messageId, String content, DateTime timestamp, ) { final result = List.from(items); final index = result.indexWhere( (item) => item.id == messageId && item is TextMessageItem, ); if (index >= 0) { final existing = result[index] as TextMessageItem; result[index] = existing.copyWith(content: content, isStreaming: false); } else { result.add( TextMessageItem( id: messageId, content: content, timestamp: timestamp, sender: MessageSender.ai, isStreaming: false, ), ); } return result; } void _upsertUiSchema( List items, String messageId, Map uiSchema, DateTime timestamp, ) { final uiItemId = '$messageId-ui'; final existingIndex = items.indexWhere((item) => item.id == uiItemId); final uiItem = ToolResultItem( id: uiItemId, callId: messageId, uiSchema: uiSchema, timestamp: timestamp, sender: MessageSender.ai, ); if (existingIndex >= 0) { items[existingIndex] = uiItem; } else { items.add(uiItem); } } void _handleToolCallStart(ToolCallStartEvent event) { final items = List.from(state.items) ..add( ToolCallItem( id: event.toolCallId, callId: event.toolCallId, toolName: event.toolCallName, args: const {}, status: ToolCallStatus.pending, timestamp: DateTime.now(), sender: MessageSender.ai, ), ); emit(state.copyWith(items: items)); } void _handleToolCallArgs(ToolCallArgsEvent event) { final items = state.items.map((item) { if (item is ToolCallItem && item.id == event.toolCallId) { return item.copyWith(args: event.args); } return item; }).toList(); emit(state.copyWith(items: items)); } void _handleToolCallEnd(ToolCallEndEvent event) { final items = state.items.map((item) { if (item is ToolCallItem && item.id == event.toolCallId) { return item.copyWith(status: ToolCallStatus.executing); } return item; }).toList(); emit(state.copyWith(items: items)); } void _handleToolCallResult(ToolCallResultEvent event) { final items = state.items.where((item) { return !(item is ToolCallItem && item.id == event.toolCallId); }).toList(); emit(state.copyWith(items: items)); } void _handleToolCallError(ToolCallErrorEvent event) { final items = state.items.map((item) { if (item is ToolCallItem && item.id == event.toolCallId) { return item.copyWith( status: ToolCallStatus.error, errorMessage: event.error, ); } return item; }).toList(); emit(state.copyWith(items: items)); } List _convertHistoryMessages(List messages) { final converted = []; for (final msg in messages) { final normalizedRole = msg.role.toLowerCase(); final isUser = normalizedRole == 'user'; final isTool = normalizedRole == 'tool' || normalizedRole == 'tools'; final sender = isUser ? MessageSender.user : MessageSender.ai; final attachments = msg.attachments .map( (attachment) => { 'url': attachment.url, 'mimeType': attachment.mimeType, }, ) .toList(); if (!isTool && (msg.content.isNotEmpty || isUser)) { converted.add( TextMessageItem( id: msg.id, content: msg.content, timestamp: msg.timestamp, sender: sender, attachments: attachments, ), ); } if (!isTool && msg.uiSchema != null) { converted.add( ToolResultItem( id: '${msg.id}-ui', callId: msg.id, uiSchema: msg.uiSchema!, timestamp: msg.timestamp, sender: MessageSender.ai, ), ); } } return converted; } DateTime? _extractDateFromItems(List items) { if (items.isEmpty) return null; return items .map( (item) => DateTime( item.timestamp.year, item.timestamp.month, item.timestamp.day, ), ) .reduce((a, b) => a.isBefore(b) ? a : b); } Future sendMessage(String content, {List? images}) async { final attachments = (images ?? const []) .map( (image) => { 'path': image.path, 'mimeType': 'image/*', }, ) .toList(); final userMessage = TextMessageItem( id: 'user-${DateTime.now().millisecondsSinceEpoch}', content: content, timestamp: DateTime.now(), sender: MessageSender.user, attachments: attachments, ); emit( state.copyWith( items: [...state.items, userMessage], isSending: true, isWaitingFirstToken: true, isStreaming: false, isCancelling: false, error: null, ), ); try { await _service.sendMessage(content, images: images); } catch (error) { emit( state.copyWith( isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, error: error.toString(), ), ); } } Future loadHistory() async { if (state.isLoadingHistory) return; emit(state.copyWith(isLoadingHistory: true)); try { final snapshot = await _service.loadHistory(); final newItems = _convertHistoryMessages(snapshot.messages); final oldestDate = _extractDateFromItems(newItems); emit( state.copyWith( items: newItems, oldestLoadedDate: oldestDate, hasEarlierHistory: snapshot.hasMore, ), ); } finally { emit(state.copyWith(isLoadingHistory: false)); } } Future loadMoreHistory() async { if (state.isLoadingHistory || !state.hasEarlierHistory) return; if (state.oldestLoadedDate == null) return; emit(state.copyWith(isLoadingHistory: true)); try { final snapshot = await _service.loadHistory( beforeDate: state.oldestLoadedDate, ); final newItems = _convertHistoryMessages(snapshot.messages); final mergedById = { for (final item in state.items) item.id: item, }; for (final item in newItems) { mergedById[item.id] = item; } final merged = mergedById.values.toList() ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final oldestDate = _extractDateFromItems(merged); emit( state.copyWith( items: merged, oldestLoadedDate: oldestDate, hasEarlierHistory: snapshot.hasMore, ), ); } finally { emit(state.copyWith(isLoadingHistory: false)); } } Future transcribeAudioFile(String filePath) { return _service.transcribeAudio(filePath); } Future 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; } } Future loadAttachmentPreview(String previewPath) async { final cached = _attachmentPreviewCache[previewPath]; if (cached != null) { return cached; } final pending = _attachmentPreviewInflight[previewPath]; if (pending != null) { return pending; } final future = (() async { try { final bytes = await _service.fetchAttachmentPreview(previewPath); _attachmentPreviewCache[previewPath] = bytes; return bytes; } catch (_) { return null; } finally { _attachmentPreviewInflight.remove(previewPath); } })(); _attachmentPreviewInflight[previewPath] = future; return future; } void clearError() { emit(state.copyWith(error: null)); } }