520 lines
14 KiB
Dart
520 lines
14 KiB
Dart
import 'dart:typed_data';
|
|
|
|
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 '../../data/models/ag_ui_event.dart';
|
|
import '../../data/models/chat_list_item.dart';
|
|
import '../../data/services/ag_ui_service.dart';
|
|
import 'agent_stage.dart';
|
|
|
|
class ChatState {
|
|
final List<ChatListItem> 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;
|
|
final AgentStage? currentStage;
|
|
|
|
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,
|
|
this.currentStage,
|
|
});
|
|
|
|
bool get isLoading =>
|
|
isSending ||
|
|
isWaitingFirstToken ||
|
|
isStreaming ||
|
|
isCancelling ||
|
|
isLoadingHistory;
|
|
|
|
static const _unset = Object();
|
|
|
|
ChatState copyWith({
|
|
List<ChatListItem>? items,
|
|
bool? isSending,
|
|
bool? isWaitingFirstToken,
|
|
bool? isStreaming,
|
|
bool? isCancelling,
|
|
bool? isLoadingHistory,
|
|
Object? currentMessageId = _unset,
|
|
Object? error = _unset,
|
|
Object? oldestLoadedDate = _unset,
|
|
bool? hasEarlierHistory,
|
|
Object? currentStage = _unset,
|
|
}) {
|
|
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,
|
|
currentStage: currentStage == _unset
|
|
? this.currentStage
|
|
: currentStage as AgentStage?,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ChatBloc extends Cubit<ChatState> {
|
|
ChatBloc({AgUiService? service, required IApiClient apiClient})
|
|
: _service = service ?? AgUiService(apiClient: apiClient),
|
|
super(const ChatState()) {
|
|
_service.onEvent = _handleEvent;
|
|
}
|
|
|
|
final AgUiService _service;
|
|
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
|
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
|
<String, Future<Uint8List?>>{};
|
|
|
|
/// Common state reset for run completion (success/error/cancel)
|
|
ChatState _resetRunState({String? error, String? currentMessageId}) {
|
|
return state.copyWith(
|
|
isSending: false,
|
|
isWaitingFirstToken: false,
|
|
isStreaming: false,
|
|
isCancelling: false,
|
|
currentMessageId: currentMessageId,
|
|
error: error,
|
|
currentStage: null,
|
|
);
|
|
}
|
|
|
|
void _handleEvent(AgUiEvent event) {
|
|
switch (event.type) {
|
|
case AgUiEventType.runStarted:
|
|
emit(
|
|
state.copyWith(
|
|
isSending: false,
|
|
isWaitingFirstToken: true,
|
|
isCancelling: false,
|
|
error: null,
|
|
currentStage: null,
|
|
),
|
|
);
|
|
case AgUiEventType.runFinished:
|
|
emit(_resetRunState());
|
|
case AgUiEventType.runError:
|
|
final errorEvent = event as RunErrorEvent;
|
|
emit(_resetRunState(error: errorEvent.message));
|
|
case AgUiEventType.stepStarted:
|
|
_handleStepStarted(event as StepStartedEvent);
|
|
case AgUiEventType.stepFinished:
|
|
_handleStepFinished(event as StepFinishedEvent);
|
|
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.unknown:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _handleStepStarted(StepStartedEvent event) {
|
|
emit(state.copyWith(currentStage: stageFromStepName(event.stepName)));
|
|
}
|
|
|
|
void _handleStepFinished(StepFinishedEvent event) {
|
|
if (state.currentStage == stageFromStepName(event.stepName)) {
|
|
emit(state.copyWith(currentStage: null));
|
|
}
|
|
}
|
|
|
|
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
|
final timestamp = DateTime.now();
|
|
final items = _updateOrAddMessage(
|
|
state.items,
|
|
event.messageId,
|
|
event.answer,
|
|
timestamp,
|
|
);
|
|
|
|
final uiSchema = event.uiSchema;
|
|
if (uiSchema != null) {
|
|
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
|
|
}
|
|
|
|
emit(
|
|
state.copyWith(
|
|
items: items,
|
|
currentMessageId: null,
|
|
isWaitingFirstToken: false,
|
|
isStreaming: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
List<ChatListItem> _updateOrAddMessage(
|
|
List<ChatListItem> items,
|
|
String messageId,
|
|
String content,
|
|
DateTime timestamp,
|
|
) {
|
|
final result = List<ChatListItem>.from(items);
|
|
final index = result.indexWhere(
|
|
(item) => item.id == messageId && item is TextMessageItem,
|
|
);
|
|
|
|
if (index >= 0) {
|
|
final existing = result[index] as TextMessageItem;
|
|
result[index] = existing.copyWith(content: content, isStreaming: false);
|
|
} else {
|
|
result.add(
|
|
TextMessageItem(
|
|
id: messageId,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
sender: MessageSender.ai,
|
|
isStreaming: false,
|
|
),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void _upsertUiSchema(
|
|
List<ChatListItem> items,
|
|
String messageId,
|
|
Map<String, dynamic> uiSchema,
|
|
DateTime timestamp,
|
|
) {
|
|
final uiItemId = '$messageId-ui';
|
|
final existingIndex = items.indexWhere((item) => item.id == uiItemId);
|
|
final uiItem = ToolResultItem(
|
|
id: uiItemId,
|
|
callId: messageId,
|
|
uiSchema: uiSchema,
|
|
timestamp: timestamp,
|
|
sender: MessageSender.ai,
|
|
);
|
|
if (existingIndex >= 0) {
|
|
items[existingIndex] = uiItem;
|
|
} else {
|
|
items.add(uiItem);
|
|
}
|
|
}
|
|
|
|
void _handleToolCallStart(ToolCallStartEvent event) {
|
|
final items = List<ChatListItem>.from(state.items)
|
|
..add(
|
|
ToolCallItem(
|
|
id: event.toolCallId,
|
|
callId: event.toolCallId,
|
|
toolName: event.toolCallName,
|
|
args: const {},
|
|
status: ToolCallStatus.pending,
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.ai,
|
|
),
|
|
);
|
|
emit(state.copyWith(items: items));
|
|
}
|
|
|
|
void _handleToolCallArgs(ToolCallArgsEvent event) {
|
|
final items = state.items.map((item) {
|
|
if (item is ToolCallItem && item.id == event.toolCallId) {
|
|
return item.copyWith(args: event.args);
|
|
}
|
|
return item;
|
|
}).toList();
|
|
emit(state.copyWith(items: items));
|
|
}
|
|
|
|
void _handleToolCallEnd(ToolCallEndEvent event) {
|
|
final items = state.items.map((item) {
|
|
if (item is ToolCallItem && item.id == event.toolCallId) {
|
|
return item.copyWith(status: ToolCallStatus.executing);
|
|
}
|
|
return item;
|
|
}).toList();
|
|
emit(state.copyWith(items: items));
|
|
}
|
|
|
|
void _handleToolCallResult(ToolCallResultEvent event) {
|
|
final timestamp = DateTime.now();
|
|
final items = state.items.where((item) {
|
|
return !(item is ToolCallItem && item.id == event.toolCallId);
|
|
}).toList();
|
|
|
|
if (event.uiSchema != null) {
|
|
_upsertById(
|
|
items,
|
|
ToolResultItem(
|
|
id: event.messageId,
|
|
callId: event.toolCallId,
|
|
uiSchema: event.uiSchema!,
|
|
timestamp: timestamp,
|
|
sender: MessageSender.ai,
|
|
),
|
|
);
|
|
} else if (event.resultSummary.isNotEmpty) {
|
|
_upsertById(
|
|
items,
|
|
TextMessageItem(
|
|
id: event.messageId,
|
|
content: event.resultSummary,
|
|
timestamp: timestamp,
|
|
sender: MessageSender.ai,
|
|
),
|
|
);
|
|
}
|
|
|
|
emit(state.copyWith(items: items));
|
|
}
|
|
|
|
void _handleToolCallError(ToolCallErrorEvent event) {
|
|
final items = state.items.map((item) {
|
|
if (item is ToolCallItem && item.id == event.toolCallId) {
|
|
return item.copyWith(
|
|
status: ToolCallStatus.error,
|
|
errorMessage: event.error,
|
|
);
|
|
}
|
|
return item;
|
|
}).toList();
|
|
emit(state.copyWith(items: items));
|
|
}
|
|
|
|
List<ChatListItem> _convertHistoryMessages(List<HistoryMessage> messages) {
|
|
final converted = <ChatListItem>[];
|
|
for (final msg in messages) {
|
|
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
|
|
final attachments = msg.attachments
|
|
.map(
|
|
(attachment) => <String, dynamic>{
|
|
'url': attachment.url,
|
|
'mimeType': attachment.mimeType,
|
|
},
|
|
)
|
|
.toList();
|
|
|
|
if (msg.content.isNotEmpty || sender == MessageSender.user) {
|
|
converted.add(
|
|
TextMessageItem(
|
|
id: msg.id,
|
|
content: msg.content,
|
|
timestamp: msg.timestamp,
|
|
sender: sender,
|
|
attachments: attachments,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (msg.uiSchema != null) {
|
|
converted.add(
|
|
ToolResultItem(
|
|
id: '${msg.id}-ui',
|
|
callId: msg.id,
|
|
uiSchema: msg.uiSchema!,
|
|
timestamp: msg.timestamp,
|
|
sender: MessageSender.ai,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return converted;
|
|
}
|
|
|
|
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, {List<XFile>? images}) async {
|
|
final attachments = (images ?? const <XFile>[])
|
|
.map(
|
|
(image) => <String, dynamic>{
|
|
'path': image.path,
|
|
'mimeType': 'image/*',
|
|
},
|
|
)
|
|
.toList();
|
|
final userMessage = TextMessageItem(
|
|
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
|
content: content,
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.user,
|
|
attachments: attachments,
|
|
);
|
|
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<void> loadHistory() async {
|
|
if (state.isLoadingHistory) return;
|
|
emit(state.copyWith(isLoadingHistory: true));
|
|
try {
|
|
final snapshot = await _service.loadHistory();
|
|
final newItems = _convertHistoryMessages(snapshot.messages);
|
|
final oldestDate = _extractDateFromItems(newItems);
|
|
emit(
|
|
state.copyWith(
|
|
items: newItems,
|
|
oldestLoadedDate: oldestDate,
|
|
hasEarlierHistory: snapshot.hasMore,
|
|
),
|
|
);
|
|
} finally {
|
|
emit(state.copyWith(isLoadingHistory: false));
|
|
}
|
|
}
|
|
|
|
Future<void> loadMoreHistory() async {
|
|
if (state.isLoadingHistory || !state.hasEarlierHistory) return;
|
|
if (state.oldestLoadedDate == null) return;
|
|
emit(state.copyWith(isLoadingHistory: true));
|
|
try {
|
|
final snapshot = await _service.loadHistory(
|
|
beforeDate: state.oldestLoadedDate,
|
|
);
|
|
final newItems = _convertHistoryMessages(snapshot.messages);
|
|
final mergedById = <String, ChatListItem>{
|
|
for (final item in state.items) item.id: item,
|
|
};
|
|
for (final item in newItems) {
|
|
mergedById[item.id] = item;
|
|
}
|
|
final merged = mergedById.values.toList()
|
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
final oldestDate = _extractDateFromItems(merged);
|
|
emit(
|
|
state.copyWith(
|
|
items: merged,
|
|
oldestLoadedDate: oldestDate,
|
|
hasEarlierHistory: snapshot.hasMore,
|
|
),
|
|
);
|
|
} finally {
|
|
emit(state.copyWith(isLoadingHistory: false));
|
|
}
|
|
}
|
|
|
|
void _upsertById(List<ChatListItem> items, ChatListItem nextItem) {
|
|
final index = items.indexWhere((item) => item.id == nextItem.id);
|
|
if (index >= 0) {
|
|
items[index] = nextItem;
|
|
return;
|
|
}
|
|
items.add(nextItem);
|
|
}
|
|
|
|
Future<String> transcribeAudioFile(String filePath) {
|
|
return _service.transcribeAudio(filePath);
|
|
}
|
|
|
|
Future<bool> 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;
|
|
}
|
|
}
|
|
|
|
Future<Uint8List?> loadAttachmentPreview(String previewPath) async {
|
|
final cached = _attachmentPreviewCache[previewPath];
|
|
if (cached != null) {
|
|
return cached;
|
|
}
|
|
final pending = _attachmentPreviewInflight[previewPath];
|
|
if (pending != null) {
|
|
return pending;
|
|
}
|
|
final future = (() async {
|
|
try {
|
|
final bytes = await _service.fetchAttachmentPreview(previewPath);
|
|
_attachmentPreviewCache[previewPath] = bytes;
|
|
return bytes;
|
|
} catch (_) {
|
|
return null;
|
|
} finally {
|
|
_attachmentPreviewInflight.remove(previewPath);
|
|
}
|
|
})();
|
|
_attachmentPreviewInflight[previewPath] = future;
|
|
return future;
|
|
}
|
|
|
|
void clearError() {
|
|
emit(state.copyWith(error: null));
|
|
}
|
|
}
|