feat: 优化前端 UI 组件与交互体验
- 优化日历、待办、消息等页面交互 - 更新 ChatBloc 与 UI Schema 渲染 - 优化联系人、首页、设置页面体验
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import 'dart:convert';
|
||||
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 'package:social_app/core/di/injection.dart';
|
||||
|
||||
import '../../data/models/ag_ui_event.dart';
|
||||
import '../../data/models/chat_list_item.dart';
|
||||
@@ -84,18 +82,17 @@ class ChatState {
|
||||
}
|
||||
|
||||
class ChatBloc extends Cubit<ChatState> {
|
||||
final AgUiService _service;
|
||||
final Map<String, String> _toolCallArgsBuffer = {};
|
||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
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:
|
||||
@@ -136,10 +133,6 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
_handleStepStarted(event as StepStartedEvent);
|
||||
case AgUiEventType.stepFinished:
|
||||
_handleStepFinished(event as StepFinishedEvent);
|
||||
case AgUiEventType.textMessageStart:
|
||||
_handleTextMessageStart(event as TextMessageStartEvent);
|
||||
case AgUiEventType.textMessageContent:
|
||||
_handleTextMessageContent(event as TextMessageContentEvent);
|
||||
case AgUiEventType.textMessageEnd:
|
||||
_handleTextMessageEnd(event as TextMessageEndEvent);
|
||||
case AgUiEventType.toolCallStart:
|
||||
@@ -152,10 +145,6 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
_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;
|
||||
}
|
||||
@@ -171,213 +160,179 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextMessageStart(TextMessageStartEvent startEvent) {
|
||||
final newMessage = TextMessageItem(
|
||||
id: startEvent.messageId,
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
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: [...state.items, newMessage],
|
||||
currentMessageId: startEvent.messageId,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
items: items,
|
||||
currentMessageId: null,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _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 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);
|
||||
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: updatedItems));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallResult(ToolCallResultEvent resultEvent) {
|
||||
final filteredItems = state.items.where((item) {
|
||||
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
|
||||
return false;
|
||||
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 true;
|
||||
return item;
|
||||
}).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]));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _handleToolCallError(ToolCallErrorEvent errorEvent) {
|
||||
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
|
||||
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: errorEvent.error,
|
||||
errorMessage: event.error,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
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);
|
||||
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/*'});
|
||||
}
|
||||
} 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;
|
||||
if (msg.content.isNotEmpty || sender == MessageSender.user) {
|
||||
converted.add(
|
||||
TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
sender: sender,
|
||||
attachments: attachments,
|
||||
),
|
||||
);
|
||||
}
|
||||
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,
|
||||
attachments: msg.attachments ?? const [],
|
||||
);
|
||||
case 'assistant':
|
||||
return TextMessageItem(
|
||||
id: msg.id,
|
||||
content: msg.content ?? '',
|
||||
timestamp: timestamp,
|
||||
if (msg.uiSchema != null) {
|
||||
converted.add(
|
||||
ToolResultItem(
|
||||
id: '${msg.id}-ui',
|
||||
callId: msg.id,
|
||||
uiSchema: msg.uiSchema!,
|
||||
timestamp: msg.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();
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
DateTime? _extractDateFromItems(List<ChatListItem> items) {
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) => DateTime(
|
||||
@@ -393,8 +348,8 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final attachments = (images ?? const <XFile>[])
|
||||
.map(
|
||||
(image) => <String, dynamic>{
|
||||
"path": image.path,
|
||||
"mimeType": "image/*",
|
||||
'path': image.path,
|
||||
'mimeType': 'image/*',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
@@ -434,7 +389,16 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (state.isLoadingHistory) return;
|
||||
emit(state.copyWith(isLoadingHistory: true));
|
||||
try {
|
||||
await _service.loadHistory();
|
||||
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));
|
||||
}
|
||||
@@ -445,69 +409,38 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (state.oldestLoadedDate == null) return;
|
||||
emit(state.copyWith(isLoadingHistory: true));
|
||||
try {
|
||||
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
void _upsertById(List<ChatListItem> items, ChatListItem nextItem) {
|
||||
final index = items.indexWhere((item) => item.id == nextItem.id);
|
||||
if (index >= 0) {
|
||||
items[index] = nextItem;
|
||||
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,
|
||||
isSending: false,
|
||||
isWaitingFirstToken: true,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
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,
|
||||
isSending: false,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
items.add(nextItem);
|
||||
}
|
||||
|
||||
Future<String> transcribeAudioFile(String filePath) {
|
||||
@@ -548,16 +481,17 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (pending != null) {
|
||||
return pending;
|
||||
}
|
||||
final future = _service
|
||||
.fetchAttachmentPreview(previewPath)
|
||||
.then((bytes) {
|
||||
_attachmentPreviewCache[previewPath] = bytes;
|
||||
return bytes;
|
||||
})
|
||||
.catchError((_) => null)
|
||||
.whenComplete(() {
|
||||
_attachmentPreviewInflight.remove(previewPath);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user