feat: 添加 Analytics 分析功能(行为追踪、错误码、协议更新)
This commit is contained in:
@@ -12,6 +12,7 @@ import 'package:social_app/core/chat/chat_list_item.dart';
|
||||
import 'package:social_app/core/chat/chat_orchestrator.dart';
|
||||
import 'package:social_app/core/chat/chat_history_repository.dart';
|
||||
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
|
||||
import 'package:social_app/core/analytics/tracker.dart';
|
||||
import 'package:social_app/core/l10n/l10n.dart';
|
||||
import 'chat_bloc_recovery_utils.dart';
|
||||
|
||||
@@ -20,6 +21,13 @@ part 'chat_bloc_send.dart';
|
||||
part 'chat_bloc_history.dart';
|
||||
part 'chat_bloc_attachments.dart';
|
||||
|
||||
typedef ChatCompletedCallback =
|
||||
void Function({
|
||||
required String conversationId,
|
||||
required int messageCount,
|
||||
required int responseTimeMs,
|
||||
});
|
||||
|
||||
class ChatState implements ChatOrchestratorState {
|
||||
@override
|
||||
final List<ChatListItem> items;
|
||||
@@ -115,12 +123,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
required ChatApi chatApi,
|
||||
ChatHistoryRepository? historyRepository,
|
||||
Future<void> Function()? onCalendarMutated,
|
||||
ChatCompletedCallback? onChatCompleted,
|
||||
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
||||
Duration recoveryTimeout = const Duration(seconds: 20),
|
||||
}) : _service =
|
||||
service ??
|
||||
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
||||
_onCalendarMutated = onCalendarMutated,
|
||||
_onChatCompleted = onChatCompleted,
|
||||
_recoveryPollInterval = recoveryPollInterval,
|
||||
_recoveryTimeout = recoveryTimeout,
|
||||
super(const ChatState()) {
|
||||
@@ -129,9 +139,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
|
||||
final AgUiService _service;
|
||||
final Future<void> Function()? _onCalendarMutated;
|
||||
final ChatCompletedCallback? _onChatCompleted;
|
||||
final Duration _recoveryPollInterval;
|
||||
final Duration _recoveryTimeout;
|
||||
String? _activeUserId;
|
||||
DateTime? _activeRunStartedAt;
|
||||
DateTime? _activeRunFirstResponseAt;
|
||||
String? _activeRunId;
|
||||
String? _activeThreadId;
|
||||
int _sessionEpoch = 0;
|
||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
@@ -259,4 +274,54 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
emit(state.copyWith(error: error.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _recordRunStarted({required String runId, required String threadId}) {
|
||||
_activeRunStartedAt = DateTime.now();
|
||||
_activeRunFirstResponseAt = null;
|
||||
_activeRunId = runId;
|
||||
_activeThreadId = threadId;
|
||||
}
|
||||
|
||||
void _recordRunFirstResponse() {
|
||||
_activeRunFirstResponseAt ??= DateTime.now();
|
||||
}
|
||||
|
||||
void _trackChatCompleted() {
|
||||
final startedAt = _activeRunStartedAt;
|
||||
if (startedAt == null) {
|
||||
return;
|
||||
}
|
||||
final firstResponseAt = _activeRunFirstResponseAt ?? DateTime.now();
|
||||
final responseTimeMs = firstResponseAt.difference(startedAt).inMilliseconds;
|
||||
final threadId = _activeThreadId?.trim();
|
||||
final runId = _activeRunId?.trim();
|
||||
final conversationId = (threadId != null && threadId.isNotEmpty)
|
||||
? threadId
|
||||
: runId;
|
||||
if (conversationId == null || conversationId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final onChatCompleted = _onChatCompleted;
|
||||
if (onChatCompleted != null) {
|
||||
onChatCompleted(
|
||||
conversationId: conversationId,
|
||||
messageCount: 1,
|
||||
responseTimeMs: responseTimeMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
AnalyticsTracker.instance.trackAgentChatCompleted(
|
||||
conversationId: conversationId,
|
||||
scenario: 'assistant',
|
||||
messageCount: 1,
|
||||
responseTimeMs: responseTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _clearRunMetrics() {
|
||||
_activeRunStartedAt = null;
|
||||
_activeRunFirstResponseAt = null;
|
||||
_activeRunId = null;
|
||||
_activeThreadId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
void _handleEvent(AgUiEvent event) {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
final runStartedEvent = event as RunStartedEvent;
|
||||
_recordRunStarted(
|
||||
runId: runStartedEvent.runId,
|
||||
threadId: runStartedEvent.threadId,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSending: false,
|
||||
@@ -17,11 +22,14 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
),
|
||||
);
|
||||
case AgUiEventType.runFinished:
|
||||
_trackChatCompleted();
|
||||
_clearRunMetrics();
|
||||
emit(
|
||||
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
||||
);
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
_clearRunMetrics();
|
||||
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
||||
emit(
|
||||
_resetRunState(
|
||||
@@ -72,6 +80,7 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
}
|
||||
|
||||
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
||||
_recordRunFirstResponse();
|
||||
final timestamp = DateTime.now();
|
||||
final items = _updateOrAddMessage(
|
||||
state.items,
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_route_observer.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/analytics/tracker.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
@@ -98,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
int _previousItemCount = 0;
|
||||
bool _previousIsLoadingHistory = false;
|
||||
bool _routeAwareSubscribed = false;
|
||||
late final DateTime _pageEnteredAt;
|
||||
int _pageClickCount = 0;
|
||||
double? _historyViewportPixels;
|
||||
double? _historyViewportMaxExtent;
|
||||
final GlobalKey<HomeInputHostState> _inputHostKey =
|
||||
@@ -121,6 +124,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
);
|
||||
_selectedImages.addAll(widget.initialSelectedImages);
|
||||
_pageEnteredAt = DateTime.now();
|
||||
final initialUserId = widget.initialUserId?.trim();
|
||||
if (initialUserId != null && initialUserId.isNotEmpty) {
|
||||
unawaited(_chatBloc.switchUser(initialUserId));
|
||||
@@ -148,6 +152,14 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final stayDurationMs = DateTime.now()
|
||||
.difference(_pageEnteredAt)
|
||||
.inMilliseconds;
|
||||
AnalyticsTracker.instance.trackPageView(
|
||||
pageName: 'home',
|
||||
stayDurationMs: stayDurationMs,
|
||||
clickCount: _pageClickCount,
|
||||
);
|
||||
_messageController.dispose();
|
||||
_scrollController.removeListener(_handleScrollChanged);
|
||||
_scrollController.dispose();
|
||||
@@ -281,10 +293,18 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return HomeFloatingHeader(
|
||||
unreadCount: _unreadCount,
|
||||
onTapSettings: () => context.push(AppRoutes.settingsMain),
|
||||
onTapCalendar: () =>
|
||||
context.push('${AppRoutes.calendarDayWeek}?from=home'),
|
||||
onTapMessages: () => context.push(AppRoutes.messageInviteList),
|
||||
onTapSettings: () {
|
||||
_trackClick('header_settings');
|
||||
context.push(AppRoutes.settingsMain);
|
||||
},
|
||||
onTapCalendar: () {
|
||||
_trackClick('header_calendar');
|
||||
context.push('${AppRoutes.calendarDayWeek}?from=home');
|
||||
},
|
||||
onTapMessages: () {
|
||||
_trackClick('header_messages');
|
||||
context.push(AppRoutes.messageInviteList);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,6 +406,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
child: HomeUnreadBadge(
|
||||
count: _chatUnreadBadgeCount,
|
||||
onTap: () {
|
||||
_trackClick('unread_badge');
|
||||
_scheduleAutoScroll(animated: true);
|
||||
if (mounted) {
|
||||
setState(() => _chatUnreadBadgeCount = 0);
|
||||
@@ -438,6 +459,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Future<void> _onLoadMore(BuildContext context) async {
|
||||
_trackClick('history_load_more');
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
await _loadMoreHistoryPreservingViewport(chatBloc);
|
||||
}
|
||||
@@ -650,9 +672,18 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
messageController: _messageController,
|
||||
onTapPlus: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||
: () => _showBottomSheet(context),
|
||||
onStopGenerating: _onStopGenerating,
|
||||
? () {
|
||||
_trackClick('record_stop');
|
||||
_stopRecording(autoSendAfterTranscribe: false);
|
||||
}
|
||||
: () {
|
||||
_trackClick('input_plus');
|
||||
_showBottomSheet(context);
|
||||
},
|
||||
onStopGenerating: () {
|
||||
_trackClick('stop_generating');
|
||||
_onStopGenerating();
|
||||
},
|
||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||
@@ -662,6 +693,15 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _trackClick(String elementId) {
|
||||
_pageClickCount += 1;
|
||||
AnalyticsTracker.instance.trackClick(
|
||||
pageName: 'home',
|
||||
elementId: elementId,
|
||||
elementType: 'button',
|
||||
);
|
||||
}
|
||||
|
||||
void _removeImage(int index) {
|
||||
setState(() {
|
||||
_selectedImages.removeAt(index);
|
||||
|
||||
@@ -53,6 +53,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
|
||||
});
|
||||
|
||||
try {
|
||||
_trackClick('send_message');
|
||||
await _chatBloc.sendMessage(content, images: images);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
Reference in New Issue
Block a user