Files
social-app/apps/lib/features/chat/presentation/bloc/chat_bloc.dart
T
qzl 4b92772535 feat: 优化前端 UI 组件与交互体验
- 优化日历、待办、消息等页面交互
- 更新 ChatBloc 与 UI Schema 渲染
- 优化联系人、首页、设置页面体验
2026-03-16 16:11:28 +08:00

516 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';
enum AgentStage { intent, execution, report }
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?>>{};
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(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
currentStage: 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,
currentStage: null,
),
);
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: _stageFromName(event.stepName)));
}
void _handleStepFinished(StepFinishedEvent event) {
if (state.currentStage == _stageFromName(event.stepName)) {
emit(state.copyWith(currentStage: null));
}
}
void _handleTextMessageEnd(TextMessageEndEvent event) {
final timestamp = DateTime.now();
final items = List<ChatListItem>.from(state.items);
final messageIndex = items.indexWhere(
(item) => item.id == event.messageId && item is TextMessageItem,
);
if (messageIndex >= 0) {
final existing = items[messageIndex] as TextMessageItem;
items[messageIndex] = existing.copyWith(
content: event.answer,
isStreaming: false,
);
} else {
items.add(
TextMessageItem(
id: event.messageId,
content: event.answer,
timestamp: timestamp,
sender: MessageSender.ai,
isStreaming: false,
),
);
}
final uiSchema = event.uiSchema;
if (uiSchema != null) {
final uiItemId = '${event.messageId}-ui';
final existingUiIndex = items.indexWhere((item) => item.id == uiItemId);
final uiItem = ToolResultItem(
id: uiItemId,
callId: event.messageId,
uiSchema: uiSchema,
timestamp: timestamp,
sender: MessageSender.ai,
);
if (existingUiIndex >= 0) {
items[existingUiIndex] = uiItem;
} else {
items.add(uiItem);
}
}
emit(
state.copyWith(
items: items,
currentMessageId: null,
isWaitingFirstToken: false,
isStreaming: false,
),
);
}
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 = <Map<String, dynamic>>[];
if (msg.url != null && msg.url!.isNotEmpty) {
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
}
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));
}
}
AgentStage? _stageFromName(String value) {
switch (value) {
case 'intent':
return AgentStage.intent;
case 'execution':
return AgentStage.execution;
case 'report':
return AgentStage.report;
default:
return null;
}
}