From e1973a986864e5337f38f5f686f8fbf192628bef Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:40:46 +0800 Subject: [PATCH] feat(chat): add ChatBloc for state management --- .../chat/presentation/bloc/chat_bloc.dart | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 apps/lib/features/chat/presentation/bloc/chat_bloc.dart diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart new file mode 100644 index 0000000..20f2fd5 --- /dev/null +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -0,0 +1,197 @@ +import 'dart:convert'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/models/ag_ui_event.dart'; +import '../../data/models/chat_list_item.dart'; +import '../../data/models/tool_result.dart'; + +class ChatState { + final List items; + final bool isLoading; + final String? currentMessageId; + final String? error; + + const ChatState({ + this.items = const [], + this.isLoading = false, + this.currentMessageId, + this.error, + }); + + static const _unset = Object(); + + ChatState copyWith({ + List? items, + bool? isLoading, + Object? currentMessageId = _unset, + Object? error = _unset, + }) { + 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?, + ); + } +} + +class AgUiService { + void Function(AgUiEvent)? onEvent; + + AgUiService({this.onEvent}); + + Future sendMessage(String content) async {} +} + +class ChatBloc extends Cubit { + final AgUiService _service; + final Map _toolCallArgsBuffer = {}; + + ChatBloc({AgUiService? service}) + : _service = service ?? AgUiService(onEvent: (_) {}), + super(const ChatState()) { + _service.onEvent = _handleEvent; + } + + void _handleEvent(AgUiEvent event) { + switch (event.type) { + case AgUiEventType.runStarted: + emit(state.copyWith(isLoading: true, error: null)); + break; + case AgUiEventType.runFinished: + emit(state.copyWith(isLoading: false, currentMessageId: null)); + break; + case AgUiEventType.runError: + final errorEvent = event as RunErrorEvent; + emit(state.copyWith(isLoading: false, error: errorEvent.message)); + break; + case AgUiEventType.textMessageStart: + final startEvent = event as TextMessageStartEvent; + 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, + ), + ); + break; + case AgUiEventType.textMessageContent: + final contentEvent = event as TextMessageContentEvent; + 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)); + break; + case AgUiEventType.textMessageEnd: + final endEvent = event as TextMessageEndEvent; + 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)); + break; + case AgUiEventType.toolCallStart: + final startEvent = event as ToolCallStartEvent; + _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])); + break; + case AgUiEventType.toolCallArgs: + final argsEvent = event as ToolCallArgsEvent; + _toolCallArgsBuffer[argsEvent.toolCallId] = + (_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta; + break; + case AgUiEventType.toolCallEnd: + final endEvent = event as ToolCallEndEvent; + 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) { + return item.copyWith( + args: parsedArgs, + status: ToolCallStatus.executing, + ); + } + return item; + }).toList(); + emit(state.copyWith(items: updatedItems)); + break; + case AgUiEventType.toolCallResult: + final resultEvent = event as ToolCallResultEvent; + final filteredItems = state.items.where((item) { + if (item.id == resultEvent.toolCallId && item is ToolCallItem) { + return false; + } + return true; + }).toList(); + final resultItem = ToolResultItem( + id: resultEvent.messageId, + callId: resultEvent.toolCallId, + uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}), + timestamp: DateTime.now(), + sender: MessageSender.ai, + ); + emit(state.copyWith(items: [...filteredItems, resultItem])); + break; + case AgUiEventType.toolCallError: + final errorEvent = event as ToolCallErrorEvent; + _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)); + break; + case AgUiEventType.unknown: + break; + } + } + + 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); + } + + void clearError() { + emit(state.copyWith(error: null)); + } +}