d37677c533
- Fix onEvent callback initialization in ChatBloc constructor - Add MockAgUiService to isolate test from mock API behavior - Remove unnecessary non-null assertions in tests
191 lines
6.3 KiB
Dart
191 lines
6.3 KiB
Dart
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';
|
|
import '../../data/services/ag_ui_service.dart';
|
|
|
|
class ChatState {
|
|
final List<ChatListItem> 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<ChatListItem>? 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 ChatBloc extends Cubit<ChatState> {
|
|
final AgUiService _service;
|
|
final Map<String, String> _toolCallArgsBuffer = {};
|
|
|
|
ChatBloc({AgUiService? service})
|
|
: _service = service ?? AgUiService(),
|
|
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<String, dynamic> parsedArgs = {};
|
|
if (argsBuffer.isNotEmpty) {
|
|
try {
|
|
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
|
|
} 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<void> 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));
|
|
}
|
|
}
|