Files
social-app/apps/lib/features/chat/presentation/bloc/chat_bloc.dart
T
zl-q 3ac09475ad feat(agent): add voice input capability and standardize tool naming
- Add voice recording with transcribe endpoint (ASR) for multimodal input
- Android: add RECORD_AUDIO and INTERNET permissions
- Refactor tool naming: frontend tools use 'front.' prefix, backend tools use 'back.'
- Migrate calendar tools: create_calendar_event -> back.mutate/list/delete events
- Add calendar_event_list.v1 and calendar_operation.v1 UI card types
- Update all Flutter and Python tests to match new tool naming conventions
- Add record package dependency for voice recording
2026-03-09 00:10:09 +08:00

393 lines
12 KiB
Dart

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<ChatListItem> 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<ChatListItem>? 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<ChatState> {
final AgUiService _service;
final Map<String, String> _toolCallArgsBuffer = {};
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
service ??
AgUiService(
apiClient:
apiClient ??
(sl.isRegistered<IApiClient>()
? sl<IApiClient>()
: 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<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) {
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<dynamic>) {
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: const []));
return;
}
final parsed = <SnapshotMessage>[];
for (final raw in rawMessages) {
if (raw is! Map<String, dynamic>) {
continue;
}
parsed.add(SnapshotMessage.fromJson(raw));
}
_handleMessagesSnapshot(MessagesSnapshotEvent(messages: parsed));
}
List<ChatListItem> _convertSnapshotMessages(List<SnapshotMessage> 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<ChatListItem> 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<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);
}
Future<void> loadHistory() async {
if (state.isLoading) return;
await _service.loadHistory();
}
Future<void> loadMoreHistory() async {
if (state.isLoading || !state.hasEarlierHistory) return;
if (state.oldestLoadedDate == null) return;
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
}
Future<void> 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<String> transcribeAudioFile(String filePath) {
return _service.transcribeAudio(filePath);
}
void clearError() {
emit(state.copyWith(error: null));
}
}