import 'dart:convert'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/di/injection.dart'; import '../../data/models/ag_ui_event.dart'; import '../../data/models/chat_list_item.dart'; import '../../data/services/ag_ui_service.dart'; class ChatState { final List items; final bool isLoading; final String? currentMessageId; final String? error; final DateTime? oldestLoadedDate; final bool hasEarlierHistory; const ChatState({ this.items = const [], this.isLoading = false, this.currentMessageId, this.error, this.oldestLoadedDate, this.hasEarlierHistory = false, }); static const _unset = Object(); ChatState copyWith({ List? items, bool? isLoading, Object? currentMessageId = _unset, Object? error = _unset, Object? oldestLoadedDate = _unset, bool? hasEarlierHistory, }) { return ChatState( items: items ?? this.items, isLoading: isLoading ?? this.isLoading, 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, ); } } class ChatBloc extends Cubit { final AgUiService _service; final Map _toolCallArgsBuffer = {}; ChatBloc({AgUiService? service, IApiClient? apiClient}) : _service = service ?? AgUiService( apiClient: apiClient ?? (sl.isRegistered() ? sl() : MockApiClient()), ), super(const ChatState()) { _service.onEvent = _handleEvent; } void _handleEvent(AgUiEvent event) { switch (event.type) { case AgUiEventType.runStarted: emit(state.copyWith(isLoading: true, error: null)); case AgUiEventType.runFinished: emit(state.copyWith(isLoading: false, currentMessageId: null)); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; emit(state.copyWith(isLoading: false, error: errorEvent.message)); case AgUiEventType.textMessageStart: _handleTextMessageStart(event as TextMessageStartEvent); case AgUiEventType.textMessageContent: _handleTextMessageContent(event as TextMessageContentEvent); 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.stateSnapshot: _handleStateSnapshot(event as StateSnapshotEvent); case AgUiEventType.messagesSnapshot: _handleMessagesSnapshot(event as MessagesSnapshotEvent); case AgUiEventType.unknown: break; } } void _handleTextMessageStart(TextMessageStartEvent startEvent) { final newMessage = TextMessageItem( id: startEvent.messageId, content: '', timestamp: DateTime.now(), sender: MessageSender.ai, isStreaming: true, ); emit( state.copyWith( items: [...state.items, newMessage], currentMessageId: startEvent.messageId, ), ); } void _handleTextMessageContent(TextMessageContentEvent contentEvent) { final updatedItems = state.items.map((item) { if (item.id == contentEvent.messageId && item is TextMessageItem) { return item.copyWith(content: item.content + contentEvent.delta); } return item; }).toList(); emit(state.copyWith(items: updatedItems)); } void _handleTextMessageEnd(TextMessageEndEvent endEvent) { final updatedItems = state.items.map((item) { if (item.id == endEvent.messageId && item is TextMessageItem) { return item.copyWith(isStreaming: false); } return item; }).toList(); emit(state.copyWith(items: updatedItems, currentMessageId: null)); } void _handleToolCallStart(ToolCallStartEvent startEvent) { _toolCallArgsBuffer[startEvent.toolCallId] = ''; final newToolCall = ToolCallItem( id: startEvent.toolCallId, callId: startEvent.toolCallId, toolName: startEvent.toolCallName, args: {}, status: ToolCallStatus.pending, timestamp: DateTime.now(), sender: MessageSender.ai, ); emit(state.copyWith(items: [...state.items, newToolCall])); } void _handleToolCallArgs(ToolCallArgsEvent argsEvent) { _toolCallArgsBuffer[argsEvent.toolCallId] = (_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta; } void _handleToolCallEnd(ToolCallEndEvent endEvent) { final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? ''; Map parsedArgs = {}; if (argsBuffer.isNotEmpty) { try { parsedArgs = jsonDecode(argsBuffer) as Map; } catch (_) {} } _toolCallArgsBuffer.remove(endEvent.toolCallId); final updatedItems = state.items.map((item) { if (item.id == endEvent.toolCallId && item is ToolCallItem) { final nextStatus = item.toolName == 'front.navigate_to_route' ? ToolCallStatus.pending : ToolCallStatus.executing; return item.copyWith(args: parsedArgs, status: nextStatus); } return item; }).toList(); emit(state.copyWith(items: updatedItems)); } void _handleToolCallResult(ToolCallResultEvent resultEvent) { final filteredItems = state.items.where((item) { if (item.id == resultEvent.toolCallId && item is ToolCallItem) { return false; } return true; }).toList(); final uiCard = resultEvent.ui; if (uiCard == null) { emit(state.copyWith(items: filteredItems)); return; } final resultItem = ToolResultItem( id: resultEvent.messageId, callId: resultEvent.toolCallId, uiCard: uiCard, timestamp: DateTime.now(), sender: MessageSender.ai, ); emit(state.copyWith(items: [...filteredItems, resultItem])); } void _handleToolCallError(ToolCallErrorEvent errorEvent) { _toolCallArgsBuffer.remove(errorEvent.toolCallId); final updatedItems = state.items.map((item) { if (item.id == errorEvent.toolCallId && item is ToolCallItem) { return item.copyWith( status: ToolCallStatus.error, errorMessage: errorEvent.error, ); } return item; }).toList(); emit(state.copyWith(items: updatedItems)); } void _handleMessagesSnapshot(MessagesSnapshotEvent snapshotEvent) { final newItems = _convertSnapshotMessages(snapshotEvent.messages); final allItems = [...newItems, ...state.items]; // Determine oldest date and history availability DateTime? newOldestDate = state.oldestLoadedDate; bool newHasEarlierHistory = false; if (newItems.isNotEmpty) { newOldestDate = _extractDateFromItems(newItems); if (newOldestDate != null) { newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate); } } else if (newOldestDate != null) { newHasEarlierHistory = _service.hasEarlierHistory(newOldestDate); } emit( state.copyWith( items: allItems, oldestLoadedDate: newOldestDate, hasEarlierHistory: newHasEarlierHistory, ), ); } void _handleStateSnapshot(StateSnapshotEvent stateSnapshotEvent) { final snapshot = stateSnapshotEvent.snapshot; if (snapshot['scope'] != 'history_day') { return; } final rawMessages = snapshot['messages']; if (rawMessages is! List) { _handleMessagesSnapshot(MessagesSnapshotEvent(messages: const [])); return; } final parsed = []; for (final raw in rawMessages) { if (raw is! Map) { continue; } parsed.add(SnapshotMessage.fromJson(raw)); } _handleMessagesSnapshot(MessagesSnapshotEvent(messages: parsed)); } List _convertSnapshotMessages(List messages) { return messages.map((msg) { final timestamp = msg.timestamp ?? DateTime.now(); switch (msg.role) { case 'user': return TextMessageItem( id: msg.id, content: msg.content ?? '', timestamp: timestamp, sender: MessageSender.user, ); case 'assistant': return TextMessageItem( id: msg.id, content: msg.content ?? '', timestamp: timestamp, sender: MessageSender.ai, ); case 'tool' when msg.ui != null: return ToolResultItem( id: msg.id, callId: msg.toolCallId ?? '', uiCard: msg.ui!, timestamp: timestamp, sender: MessageSender.ai, ); default: return TextMessageItem( id: msg.id, content: msg.content ?? '', timestamp: timestamp, sender: MessageSender.ai, ); } }).toList(); } 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) async { final userMessage = TextMessageItem( id: 'user-${DateTime.now().millisecondsSinceEpoch}', content: content, timestamp: DateTime.now(), sender: MessageSender.user, ); emit(state.copyWith(items: [...state.items, userMessage])); await _service.sendMessage(content); } Future loadHistory() async { if (state.isLoading) return; await _service.loadHistory(); } Future loadMoreHistory() async { if (state.isLoading || !state.hasEarlierHistory) return; if (state.oldestLoadedDate == null) return; await _service.loadHistory(beforeDate: state.oldestLoadedDate); } Future approveToolCall(String toolCallId) async { ToolCallItem? target; for (final item in state.items) { if (item is ToolCallItem && item.callId == toolCallId) { target = item; break; } } if (target == null) { return; } final updatedItems = state.items.map((item) { if (item is ToolCallItem && item.callId == toolCallId) { return item.copyWith( status: ToolCallStatus.executing, errorMessage: null, ); } return item; }).toList(); emit(state.copyWith(items: updatedItems, isLoading: true, error: null)); try { await _service.approveToolCall( toolCallId: target.callId, toolName: target.toolName, args: target.args, ); } catch (error) { final failedItems = state.items.map((item) { if (item is ToolCallItem && item.callId == toolCallId) { return item.copyWith( status: ToolCallStatus.error, errorMessage: error.toString(), ); } return item; }).toList(); emit( state.copyWith( items: failedItems, isLoading: false, error: error.toString(), ), ); } } Future transcribeAudioFile(String filePath) { return _service.transcribeAudio(filePath); } void clearError() { emit(state.copyWith(error: null)); } }