import 'dart:convert'; 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 '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 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; 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, }); 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, }) { 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, ); } } 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( isSending: false, isWaitingFirstToken: true, isCancelling: false, error: null, ), ); case AgUiEventType.runFinished: emit( state.copyWith( isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, currentMessageId: null, ), ); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; emit( state.copyWith( isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, currentMessageId: null, 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, isWaitingFirstToken: false, isStreaming: true, ), ); } 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, isStreaming: false, ), ); } 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, {List? images}) async { final userMessage = TextMessageItem( id: 'user-${DateTime.now().millisecondsSinceEpoch}', content: content, timestamp: DateTime.now(), sender: MessageSender.user, ); 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 { await _service.loadHistory(); } 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 { await _service.loadHistory(beforeDate: state.oldestLoadedDate); } finally { emit(state.copyWith(isLoadingHistory: false)); } } 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, isSending: false, isWaitingFirstToken: true, isStreaming: false, isCancelling: false, 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, isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, error: error.toString(), ), ); } } 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; } } void clearError() { emit(state.copyWith(error: null)); } }