diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 16ecc29..68ee6d2 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..c73a2f8 100644 Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..c1c874c 100644 Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..cb855a2 100644 Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..f548e67 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..2a18419 100644 Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj index cb86fcc..08d2df0 100644 --- a/apps/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -484,7 +484,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..25cee57 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..eaab8fc 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..ab7b321 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..bedeec5 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..9f4894e 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..69fcb4f 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..2f03318 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..ab7b321 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..29b3546 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..716a015 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..b3fb4ac Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..be520cd Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..4cb0c7e Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..133d810 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..716a015 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..ddcd7a2 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..c73a2f8 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..f548e67 Binary files /dev/null and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..ae76645 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..afd3f9c 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..45ef8eb 100644 Binary files a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index 3b51aff..19c86b8 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Social App + 灵可析 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - social_app + 灵可析 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 0f11e6d..16f9fc2 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -25,6 +25,7 @@ class AgUiService { final MockHistoryService _historyService; final Map> _mockSseLinesByThread = {}; final Map _lastEventIdByThread = {}; + int _activeStreamToken = 0; String? _threadId; bool _hasMoreHistory = false; @@ -41,6 +42,7 @@ class AgUiService { } Future sendMessage(String content) async { + final streamToken = ++_activeStreamToken; final runInput = _buildRunInput(content: content); final response = await _apiClient.post>( '/api/v1/agent/runs', @@ -55,7 +57,7 @@ class AgUiService { throw StateError('Missing threadId in /agent/runs response'); } _threadId = threadId; - await _streamEventsFromApi(threadId); + await _streamEventsFromApi(threadId, streamToken: streamToken); } Future loadHistory({DateTime? beforeDate}) async { @@ -105,6 +107,7 @@ class AgUiService { required String toolName, required Map args, }) async { + final streamToken = ++_activeStreamToken; final threadId = _threadId; if (threadId == null || threadId.isEmpty) { throw StateError('Missing threadId for resume'); @@ -150,7 +153,7 @@ class AgUiService { _threadId = responseThreadId; } } - await _streamEventsFromApi(threadId); + await _streamEventsFromApi(threadId, streamToken: streamToken); } bool hasEarlierHistory(DateTime fromDate) { @@ -160,7 +163,14 @@ class AgUiService { return _hasMoreHistory; } - Future _streamEventsFromApi(String threadId) async { + Future cancelCurrentRun() async { + _activeStreamToken += 1; + } + + Future _streamEventsFromApi( + String threadId, { + required int streamToken, + }) async { final lastEventId = _lastEventIdByThread[threadId]; final headers = {'Accept': 'text/event-stream'}; if (lastEventId != null && lastEventId.isNotEmpty) { @@ -175,6 +185,9 @@ class AgUiService { String? eventId; final dataBuffer = StringBuffer(); await for (final line in sseLines) { + if (streamToken != _activeStreamToken) { + break; + } if (line.isEmpty) { if (dataBuffer.isNotEmpty) { final raw = dataBuffer.toString(); diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 7c59430..218ec02 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -11,7 +11,11 @@ import '../../data/services/ag_ui_service.dart'; class ChatState { final List items; - final bool isLoading; + final bool isSending; + final bool isWaitingFirstToken; + final bool isStreaming; + final bool isCancelling; + final bool isLoadingHistory; final String? currentMessageId; final String? error; final DateTime? oldestLoadedDate; @@ -19,18 +23,33 @@ class ChatState { const ChatState({ this.items = const [], - this.isLoading = false, + this.isSending = false, + this.isWaitingFirstToken = false, + this.isStreaming = false, + this.isCancelling = false, + this.isLoadingHistory = false, this.currentMessageId, this.error, this.oldestLoadedDate, this.hasEarlierHistory = false, }); + bool get isLoading => + isSending || + isWaitingFirstToken || + isStreaming || + isCancelling || + isLoadingHistory; + static const _unset = Object(); ChatState copyWith({ List? items, - bool? isLoading, + bool? isSending, + bool? isWaitingFirstToken, + bool? isStreaming, + bool? isCancelling, + bool? isLoadingHistory, Object? currentMessageId = _unset, Object? error = _unset, Object? oldestLoadedDate = _unset, @@ -38,7 +57,11 @@ class ChatState { }) { return ChatState( items: items ?? this.items, - isLoading: isLoading ?? this.isLoading, + 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?, @@ -72,12 +95,36 @@ class ChatBloc extends Cubit { void _handleEvent(AgUiEvent event) { switch (event.type) { case AgUiEventType.runStarted: - emit(state.copyWith(isLoading: true, error: null)); + emit( + state.copyWith( + isSending: false, + isWaitingFirstToken: true, + isCancelling: false, + error: null, + ), + ); case AgUiEventType.runFinished: - emit(state.copyWith(isLoading: false, currentMessageId: null)); + emit( + state.copyWith( + isSending: false, + isWaitingFirstToken: false, + isStreaming: false, + isCancelling: false, + currentMessageId: null, + ), + ); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; - emit(state.copyWith(isLoading: false, error: errorEvent.message)); + emit( + state.copyWith( + isSending: false, + isWaitingFirstToken: false, + isStreaming: false, + isCancelling: false, + currentMessageId: null, + error: errorEvent.message, + ), + ); case AgUiEventType.textMessageStart: _handleTextMessageStart(event as TextMessageStartEvent); case AgUiEventType.textMessageContent: @@ -115,6 +162,8 @@ class ChatBloc extends Cubit { state.copyWith( items: [...state.items, newMessage], currentMessageId: startEvent.messageId, + isWaitingFirstToken: false, + isStreaming: true, ), ); } @@ -136,7 +185,13 @@ class ChatBloc extends Cubit { } return item; }).toList(); - emit(state.copyWith(items: updatedItems, currentMessageId: null)); + emit( + state.copyWith( + items: updatedItems, + currentMessageId: null, + isStreaming: false, + ), + ); } void _handleToolCallStart(ToolCallStartEvent startEvent) { @@ -319,20 +374,50 @@ class ChatBloc extends Cubit { timestamp: DateTime.now(), sender: MessageSender.user, ); - emit(state.copyWith(items: [...state.items, userMessage])); - await _service.sendMessage(content); + emit( + state.copyWith( + items: [...state.items, userMessage], + isSending: true, + isWaitingFirstToken: true, + isStreaming: false, + isCancelling: false, + error: null, + ), + ); + try { + await _service.sendMessage(content); + } catch (error) { + emit( + state.copyWith( + isSending: false, + isWaitingFirstToken: false, + isStreaming: false, + isCancelling: false, + error: error.toString(), + ), + ); + } } Future loadHistory() async { - if (state.isLoading) return; - await _service.loadHistory(); + if (state.isLoadingHistory) return; + emit(state.copyWith(isLoadingHistory: true)); + try { + await _service.loadHistory(); + } finally { + emit(state.copyWith(isLoadingHistory: false)); + } } Future loadMoreHistory() async { - if (state.isLoading || !state.hasEarlierHistory) return; + if (state.isLoadingHistory || !state.hasEarlierHistory) return; if (state.oldestLoadedDate == null) return; - - await _service.loadHistory(beforeDate: state.oldestLoadedDate); + emit(state.copyWith(isLoadingHistory: true)); + try { + await _service.loadHistory(beforeDate: state.oldestLoadedDate); + } finally { + emit(state.copyWith(isLoadingHistory: false)); + } } Future approveToolCall(String toolCallId) async { @@ -355,7 +440,16 @@ class ChatBloc extends Cubit { } return item; }).toList(); - emit(state.copyWith(items: updatedItems, isLoading: true, error: null)); + emit( + state.copyWith( + items: updatedItems, + isSending: false, + isWaitingFirstToken: true, + isStreaming: false, + isCancelling: false, + error: null, + ), + ); try { await _service.approveToolCall( toolCallId: target.callId, @@ -375,7 +469,10 @@ class ChatBloc extends Cubit { emit( state.copyWith( items: failedItems, - isLoading: false, + isSending: false, + isWaitingFirstToken: false, + isStreaming: false, + isCancelling: false, error: error.toString(), ), ); @@ -386,6 +483,31 @@ class ChatBloc extends Cubit { return _service.transcribeAudio(filePath); } + Future 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; + } + } + void clearError() { emit(state.copyWith(error: null)); } diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 3269590..707ca23 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -31,6 +31,8 @@ const _rippleDurationMs = 1200; const _recordingDotSize = 10.0; const _transcribingSpinnerSize = 18.0; const _transcribingStrokeWidth = 2.0; +const _inputActionButtonKey = ValueKey('home_input_action_button'); +const _inputActionIconKey = ValueKey('home_input_action_icon'); /// 颜色常量 const _chatBgColor = Color(0xFFF8FAFC); @@ -40,6 +42,7 @@ class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; final Future Function(String transcript)? onAutoSendTranscript; + final ChatBloc? chatBloc; final bool autoLoadHistory; const HomeScreen({ @@ -47,6 +50,7 @@ class HomeScreen extends StatefulWidget { this.voiceRecorder, this.onTranscribeAudio, this.onAutoSendTranscript, + this.chatBloc, this.autoLoadHistory = true, }); @@ -72,7 +76,7 @@ class _HomeScreenState extends State void initState() { super.initState(); _messageController.addListener(_onMessageChanged); - _chatBloc = ChatBloc(); + _chatBloc = widget.chatBloc ?? ChatBloc(); _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; @@ -93,7 +97,9 @@ class _HomeScreenState extends State _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); - _chatBloc.close(); + if (widget.chatBloc == null) { + _chatBloc.close(); + } RouteNavigationTool.instance.clearNavigator(); super.dispose(); } @@ -131,7 +137,7 @@ class _HomeScreenState extends State children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), - _buildInputContainer(context), + _buildInputContainer(context, state), ], ), ), @@ -185,49 +191,100 @@ class _HomeScreenState extends State } Widget _buildChatArea(BuildContext context, ChatState state) { - if (state.isLoading && state.items.isEmpty) { + final showWaitingIndicator = + state.isWaitingFirstToken || state.isStreaming || state.isCancelling; + + if (state.isLoadingHistory && state.items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (state.items.isEmpty) { - return const Center( - child: Text( - '开始对话吧', - style: TextStyle(fontSize: 16, color: AppColors.slate400), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Expanded( + child: Center( + child: Text( + '开始对话吧', + style: TextStyle(fontSize: 16, color: AppColors.slate400), + ), + ), + ), + if (showWaitingIndicator) _buildWaitingIndicator(), + ], ); } - return RefreshIndicator( - onRefresh: () => _onRefresh(context), - child: ListView.builder( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(_defaultPadding), - itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0 && state.hasEarlierHistory) { - return _buildLoadMoreButton(context, state.isLoading); - } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: RefreshIndicator( + onRefresh: () => _onRefresh(context), + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(_defaultPadding), + itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0 && state.hasEarlierHistory) { + return _buildLoadMoreButton(context, state.isLoadingHistory); + } - final itemIndex = state.hasEarlierHistory ? index - 1 : index; - final item = state.items[itemIndex]; + final itemIndex = state.hasEarlierHistory ? index - 1 : index; + final item = state.items[itemIndex]; - final showDateDivider = - itemIndex == 0 || - !_isSameDay(state.items[itemIndex - 1].timestamp, item.timestamp); + final showDateDivider = + itemIndex == 0 || + !_isSameDay( + state.items[itemIndex - 1].timestamp, + item.timestamp, + ); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showDateDivider) _buildDateDivider(item.timestamp), - Padding( - padding: const EdgeInsets.only(bottom: _itemSpacing), - child: _buildChatItem(item), - ), - ], - ); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDateDivider) _buildDateDivider(item.timestamp), + Padding( + padding: const EdgeInsets.only(bottom: _itemSpacing), + child: _buildChatItem(item), + ), + ], + ); + }, + ), + ), + ), + if (showWaitingIndicator) _buildWaitingIndicator(), + ], + ); + } + + Widget _buildWaitingIndicator() { + return Padding( + padding: const EdgeInsets.fromLTRB( + _defaultPadding, + 0, + _defaultPadding, + _defaultPadding, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + SizedBox( + width: _transcribingSpinnerSize, + height: _transcribingSpinnerSize, + child: CircularProgressIndicator( + strokeWidth: _transcribingStrokeWidth, + color: AppColors.blue600, + ), + ), + SizedBox(width: 8), + Text( + '正在思考...', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + ), + ], ), ); } @@ -406,7 +463,9 @@ class _HomeScreenState extends State return UiSchemaRenderer.render(item.uiCard); } - Widget _buildInputContainer(BuildContext context) { + Widget _buildInputContainer(BuildContext context, ChatState state) { + final isWaitingAgent = + state.isWaitingFirstToken || state.isStreaming || state.isCancelling; return Container( padding: const EdgeInsets.all(_inputPadding), color: _chatBgColor, @@ -471,10 +530,13 @@ class _HomeScreenState extends State ), const SizedBox(width: 8), GestureDetector( + key: _inputActionButtonKey, onTap: _isTranscribing ? null : _isRecording ? () => _stopRecording(autoSendAfterTranscribe: true) + : isWaitingAgent + ? () => _onStopGenerating(context) : _hasMessage ? () => _sendMessage(context) : _startRecording, @@ -488,11 +550,14 @@ class _HomeScreenState extends State ), ) : Icon( - _isRecording || _hasMessage + key: _inputActionIconKey, + _isRecording || isWaitingAgent + ? LucideIcons.square + : _hasMessage ? LucideIcons.send : LucideIcons.mic, size: _iconSize, - color: _isRecording || _hasMessage + color: _isRecording || isWaitingAgent || _hasMessage ? AppColors.blue600 : AppColors.slate500, ), @@ -511,7 +576,7 @@ class _HomeScreenState extends State if (content.isEmpty) return; FocusScope.of(context).unfocus(); _messageController.clear(); - context.read().sendMessage(content); + await context.read().sendMessage(content); WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -524,6 +589,16 @@ class _HomeScreenState extends State }); } + Future _onStopGenerating(BuildContext context) async { + final canceled = await context.read().cancelCurrentRun(); + if (!mounted) { + return; + } + if (canceled) { + Toast.show(context, '已停止等待回复', type: ToastType.info); + } + } + Widget _buildListeningIndicator() { return SizedBox( height: _inputMinHeight, diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index f0783d8..8718dba 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -31,8 +31,17 @@ dev_dependencies: mocktail: ^1.0.4 json_serializable: ^6.7.1 build_runner: ^2.4.8 + flutter_launcher_icons: ^0.14.0 flutter: uses-material-design: true assets: - assets/images/ + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/images/logo.png" + adaptive_icon_background: "#FFFFFF" + adaptive_icon_foreground: "assets/images/logo.png" + remove_alpha_ios: true diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart index ed4c790..9b13b4c 100644 --- a/apps/test/features/chat/chat_bloc_test.dart +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -12,6 +12,15 @@ class MockAgUiService extends AgUiService { Future sendMessage(String content) async {} } +class _ThrowingAgUiService extends AgUiService { + _ThrowingAgUiService() : super(onEvent: (_) {}); + + @override + Future sendMessage(String content) async { + throw StateError('network down'); + } +} + void main() { late ChatBloc chatBloc; late AgUiService service; @@ -29,6 +38,9 @@ void main() { test('initial state is empty', () { expect(chatBloc.state.items, isEmpty); expect(chatBloc.state.isLoading, false); + expect(chatBloc.state.isSending, false); + expect(chatBloc.state.isWaitingFirstToken, false); + expect(chatBloc.state.isStreaming, false); expect(chatBloc.state.currentMessageId, isNull); expect(chatBloc.state.error, isNull); }); @@ -40,6 +52,12 @@ void main() { expect: () => [ isA() .having((state) => state.items.length, 'items length', 1) + .having((state) => state.isSending, 'isSending', true) + .having( + (state) => state.isWaitingFirstToken, + 'isWaitingFirstToken', + true, + ) .having( (state) => state.items.first, 'first item', @@ -56,15 +74,13 @@ void main() { 'textMessageStart event adds AI message with streaming', build: () => chatBloc, act: (bloc) { - bloc.emit(chatBloc.state.copyWith(isLoading: true)); + bloc.emit(chatBloc.state.copyWith(isStreaming: true)); service.onEvent( TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'), ); }, expect: () => [ - isA() - .having((s) => s.isLoading, 'isLoading', true) - .having((s) => s.isLoading, 'isLoading', true), + isA().having((s) => s.isStreaming, 'isStreaming', true), isA() .having((s) => s.items.length, 'items length', 1) .having((s) => s.currentMessageId, 'currentMessageId', 'msg_1') @@ -128,6 +144,7 @@ void main() { expect: () => [ isA() .having((s) => s.currentMessageId, 'currentMessageId', isNull) + .having((s) => s.isStreaming, 'isStreaming', false) .having( (s) => (s.items.first as TextMessageItem).isStreaming, 'isStreaming', @@ -145,6 +162,7 @@ void main() { expect: () => [ isA() .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true) .having((s) => s.error, 'error', isNull), ], ); @@ -152,7 +170,7 @@ void main() { blocTest( 'runFinished sets isLoading to false', build: () => chatBloc, - seed: () => const ChatState(isLoading: true), + seed: () => const ChatState(isWaitingFirstToken: true), act: (bloc) { service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1')); }, @@ -166,7 +184,7 @@ void main() { blocTest( 'runError sets error message', build: () => chatBloc, - seed: () => const ChatState(isLoading: true), + seed: () => const ChatState(isWaitingFirstToken: true), act: (bloc) { service.onEvent( RunErrorEvent(message: 'Something went wrong', code: 'ERR'), @@ -175,10 +193,40 @@ void main() { expect: () => [ isA() .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.currentMessageId, 'currentMessageId', isNull) .having((s) => s.error, 'error', 'Something went wrong'), ], ); + blocTest( + 'cancelCurrentRun exits waiting states', + build: () => chatBloc, + seed: () => const ChatState(isWaitingFirstToken: true), + act: (bloc) => bloc.cancelCurrentRun(), + expect: () => [ + isA().having((s) => s.isCancelling, 'isCancelling', true), + isA() + .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) + .having((s) => s.isStreaming, 'isStreaming', false) + .having((s) => s.isCancelling, 'isCancelling', false), + ], + ); + + blocTest( + 'sendMessage failure emits error and exits waiting state', + build: () => ChatBloc(service: _ThrowingAgUiService()), + act: (bloc) => bloc.sendMessage('hello'), + expect: () => [ + isA() + .having((s) => s.isSending, 'isSending', true) + .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true), + isA() + .having((s) => s.isSending, 'isSending', false) + .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) + .having((s) => s.error, 'error', contains('network down')), + ], + ); + blocTest( 'clearError removes error', build: () => chatBloc, diff --git a/apps/test/features/home/ui/screens/home_screen_test.dart b/apps/test/features/home/ui/screens/home_screen_test.dart index 37de2be..89d9abb 100644 --- a/apps/test/features/home/ui/screens/home_screen_test.dart +++ b/apps/test/features/home/ui/screens/home_screen_test.dart @@ -5,6 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:social_app/core/api/api_exception.dart'; +import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; +import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; +import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; import 'package:social_app/features/home/data/voice_recorder.dart'; import 'package:social_app/features/home/ui/screens/home_screen.dart'; @@ -29,7 +32,26 @@ class _FakeVoiceRecorder implements VoiceRecorder { Future dispose() async {} } +class _WaitingAgUiService extends AgUiService { + _WaitingAgUiService() : super(onEvent: (_) {}); + + final Completer _pending = Completer(); + + @override + Future sendMessage(String content) async { + onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); + return _pending.future; + } +} + void main() { + IconData _inputActionIcon(WidgetTester tester) { + final icon = tester.widget( + find.byKey(const ValueKey('home_input_action_icon')), + ); + return icon.icon!; + } + group('HomeScreen Widget Tests', () { testWidgets('displays input field', (WidgetTester tester) async { await tester.pumpWidget( @@ -79,8 +101,7 @@ void main() { expect(fakeRecorder.started, true); expect(find.text('正在聆听...'), findsOneWidget); - expect(find.byIcon(LucideIcons.square), findsOneWidget); - expect(find.byIcon(LucideIcons.send), findsOneWidget); + expect(_inputActionIcon(tester), LucideIcons.square); }); testWidgets('tap send while recording transcribes and auto sends message', ( @@ -105,9 +126,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); - await tester.tap(find.byIcon(LucideIcons.send)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(const Duration(milliseconds: 300)); expect(sentTranscript, '语音自动发送'); @@ -127,18 +148,19 @@ void main() { expect(filePath.endsWith('.wav'), true); return '语音转文字结果'; }, + onAutoSendTranscript: (_) async {}, ), ), ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); - await tester.tap(find.byIcon(LucideIcons.square)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); expect(find.text('语音识别中...'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1)); }); testWidgets('tap stop shows readable unauthorized message', ( @@ -158,9 +180,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); - await tester.tap(find.byIcon(LucideIcons.square)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('请重新登录'), findsOneWidget); @@ -182,9 +204,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); - await tester.tap(find.byIcon(LucideIcons.square)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget); @@ -203,14 +225,15 @@ void main() { voiceRecorder: fakeRecorder, autoLoadHistory: false, onTranscribeAudio: (_) => completer.future, + onAutoSendTranscript: (_) async {}, ), ), ); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); - await tester.tap(find.byIcon(LucideIcons.square)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); expect(find.text('语音识别中...'), findsOneWidget); @@ -237,7 +260,7 @@ void main() { ); expect(editableBefore.widget.focusNode.hasFocus, isTrue); - await tester.tap(find.byIcon(LucideIcons.send)); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); await tester.pump(); final editableAfter = tester.state( @@ -247,5 +270,33 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); }); + + testWidgets('shows stop icon and waiting indicator while waiting agent', ( + WidgetTester tester, + ) async { + final chatBloc = ChatBloc(service: _WaitingAgUiService()); + await tester.pumpWidget( + MaterialApp( + home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'hello'); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); + await tester.pump(); + + expect(_inputActionIcon(tester), LucideIcons.square); + expect(find.text('正在思考...'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); + await tester.pump(); + + expect(find.text('已停止等待回复'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + + await chatBloc.close(); + }); }); } diff --git a/backend/src/core/agent/application/run_service.py b/backend/src/core/agent/application/run_service.py index 8f65e68..e553d7f 100644 --- a/backend/src/core/agent/application/run_service.py +++ b/backend/src/core/agent/application/run_service.py @@ -13,8 +13,8 @@ from core.agent.domain.agui_input import ( ) from core.agent.application.runtime_loop_service import RuntimeLoopService from core.agent.application.runtime_data_service import RuntimeDataService -from core.agent.application.session_state_persistence import SessionStatePersistence from core.agent.application.session_state_persistence import ( + SessionStatePersistence, ToolResultStorage, persist_tool_result_payload, ) @@ -179,7 +179,6 @@ class RunService: seq=next_seq, role=AgentChatMessageRole.USER, content=user_input, - model_code=model_code, metadata=MessageMetadataUserInput().model_dump(), ) pending_tool_call_id: str | None = None diff --git a/backend/src/core/agent/infrastructure/crewai/runtime_stage_runner.py b/backend/src/core/agent/infrastructure/crewai/runtime_stage_runner.py index 81a4880..8808715 100644 --- a/backend/src/core/agent/infrastructure/crewai/runtime_stage_runner.py +++ b/backend/src/core/agent/infrastructure/crewai/runtime_stage_runner.py @@ -4,7 +4,7 @@ from typing import Any, Callable from crewai import Agent, Crew, LLM, Process, Task from crewai.agents import parser as crew_parser -from litellm import completion, completion_cost +from litellm import completion from core.agent.domain.system_agent_config import SystemAgentLLMConfig from core.agent.infrastructure.config.resolver import ResolvedAgentConfig @@ -17,7 +17,11 @@ from core.agent.infrastructure.crewai.runtime_tools import ( PendingFrontendToolCall, resolve_stage_crewai_tools, ) -from core.agent.infrastructure.litellm.usage_tracker import UsageCost +from core.agent.infrastructure.litellm.pricing import calculate_tiered_model_cost +from core.agent.infrastructure.litellm.usage_tracker import ( + UsageCost, + extract_usage_and_cost, +) from core.agent.prompt import runtime_stage_prompts from core.logging import get_logger @@ -25,6 +29,31 @@ from core.logging import get_logger logger = get_logger("core.agent.infrastructure.crewai.runtime_stage_runner") +class LiteLLMUsageCaptureCallback: + def __init__(self) -> None: + self.captured_usage: dict[str, Any] | None = None + + @staticmethod + def _normalize_usage(usage_payload: object) -> dict[str, Any] | None: + if isinstance(usage_payload, dict): + return usage_payload + model_dump = getattr(usage_payload, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + return None + + def log_success_event(self, **kwargs: Any) -> None: + response_obj = kwargs.get("response_obj") + if not isinstance(response_obj, dict): + return + normalized = self._normalize_usage(response_obj.get("usage")) + if normalized is None: + return + self.captured_usage = normalized + + def _tool_names(tools_payload: list[dict[str, object]]) -> list[str]: names: list[str] = [] for item in tools_payload: @@ -69,24 +98,37 @@ def _output_diagnostics(*, text: str, tool_names: list[str]) -> dict[str, object } +def extract_usage_from_captured_payload( + *, + captured_usage: dict[str, Any], + model: str, +) -> UsageCost: + usage = extract_usage_and_cost( + { + "model": model, + "usage": captured_usage, + } + ) + return usage + + def extract_usage_from_crew_output(*, output: object, model: str) -> UsageCost: token_usage = getattr(output, "token_usage", None) prompt_tokens = int(getattr(token_usage, "prompt_tokens", 0) or 0) completion_tokens = int(getattr(token_usage, "completion_tokens", 0) or 0) total_tokens = int(getattr(token_usage, "total_tokens", 0) or 0) + cached_prompt_tokens = int(getattr(token_usage, "cached_prompt_tokens", 0) or 0) if total_tokens == 0: total_tokens = prompt_tokens + completion_tokens - try: - cost = float( - completion_cost( - model=model, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - or 0.0 + cost = float( + calculate_tiered_model_cost( + model_name=model, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cached_prompt_tokens=cached_prompt_tokens, ) - except Exception: - cost = 0.0 + or 0.0 + ) return UsageCost( prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, @@ -134,32 +176,32 @@ def run_stage_with_crewai( content = getattr(message, "content", None) if isinstance(content, str): raw_text = content - usage_obj = getattr(response_any, "usage", None) - prompt_tokens = int(getattr(usage_obj, "prompt_tokens", 0) or 0) - completion_tokens = int(getattr(usage_obj, "completion_tokens", 0) or 0) - total_tokens = int(getattr(usage_obj, "total_tokens", 0) or 0) - if total_tokens == 0: - total_tokens = prompt_tokens + completion_tokens try: - cost = float( - completion_cost( - model=litellm_model, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - or 0.0 + response_dict = ( + response_any.model_dump() + if hasattr(response_any, "model_dump") + else dict(response_any) ) + if "model" not in response_dict: + response_dict["model"] = litellm_model + usage = extract_usage_and_cost(response_dict) except Exception: - cost = 0.0 - usage = UsageCost( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, - cost=cost, - ) + usage_obj = getattr(response_any, "usage", None) + prompt_tokens = int(getattr(usage_obj, "prompt_tokens", 0) or 0) + completion_tokens = int(getattr(usage_obj, "completion_tokens", 0) or 0) + total_tokens = int(getattr(usage_obj, "total_tokens", 0) or 0) + if total_tokens == 0: + total_tokens = prompt_tokens + completion_tokens + usage = UsageCost( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + cost=0.0, + ) return raw_text, usage, [], None calls: list[dict[str, Any]] = [] + usage_callback = LiteLLMUsageCaptureCallback() crew_tools = resolve_stage_crewai_tools( tools_payload=tools_payload, calls=calls, @@ -173,6 +215,8 @@ def run_stage_with_crewai( temperature=llm_config.temperature, max_tokens=llm_config.max_tokens, timeout=llm_config.timeout_seconds, + stream=True, + callbacks=[usage_callback], ) agent = Agent( role=agent_template.role, @@ -218,7 +262,14 @@ def run_stage_with_crewai( ], pending_tool=str(pending.payload.get("name")), ) - return "", UsageCost(0, 0, 0, 0.0), calls, pending.payload + if usage_callback.captured_usage is not None: + usage = extract_usage_from_captured_payload( + captured_usage=usage_callback.captured_usage, + model=litellm_model, + ) + else: + usage = UsageCost(0, 0, 0, 0.0) + return "", usage, calls, pending.payload output_text = extract_crew_output_text(output) logger.info( @@ -231,5 +282,11 @@ def run_stage_with_crewai( ], diagnostics=_output_diagnostics(text=output_text, tool_names=stage_tool_names), ) - usage = extract_usage_from_crew_output(output=output, model=litellm_model) + if usage_callback.captured_usage is not None: + usage = extract_usage_from_captured_payload( + captured_usage=usage_callback.captured_usage, + model=litellm_model, + ) + else: + usage = extract_usage_from_crew_output(output=output, model=litellm_model) return output_text, usage, calls, None diff --git a/backend/src/core/agent/infrastructure/litellm/pricing.py b/backend/src/core/agent/infrastructure/litellm/pricing.py index dc4d871..69abe1c 100644 --- a/backend/src/core/agent/infrastructure/litellm/pricing.py +++ b/backend/src/core/agent/infrastructure/litellm/pricing.py @@ -36,9 +36,22 @@ QWEN35_FLASH_TIERED_PRICING: tuple[TieredModelPricing, ...] = ( ), ) +DEEPSEEK_CHAT_TIERED_PRICING: tuple[TieredModelPricing, ...] = ( + TieredModelPricing( + max_prompt_tokens=10_000_000, + input_cost_per_token=2.0 / 1_000_000, + output_cost_per_token=3.0 / 1_000_000, + cache_create_cost_per_token=2.0 / 1_000_000, + cache_hit_cost_per_token=0.2 / 1_000_000, + ), +) + _MODEL_TIERED_PRICING: dict[str, tuple[TieredModelPricing, ...]] = { "dashscope/qwen3.5-flash": QWEN35_FLASH_TIERED_PRICING, + "qwen3.5-flash": QWEN35_FLASH_TIERED_PRICING, + "deepseek/deepseek-chat": DEEPSEEK_CHAT_TIERED_PRICING, + "deepseek-chat": DEEPSEEK_CHAT_TIERED_PRICING, } @@ -61,12 +74,21 @@ def calculate_tiered_model_cost( model_name: str, prompt_tokens: int, completion_tokens: int, + cached_prompt_tokens: int = 0, ) -> float | None: tier = get_tiered_pricing(model_name=model_name, prompt_tokens=prompt_tokens) if tier is None: return None - return ( - prompt_tokens * tier.input_cost_per_token - + completion_tokens * tier.output_cost_per_token + normalized_prompt_tokens = max(int(prompt_tokens), 0) + normalized_completion_tokens = max(int(completion_tokens), 0) + normalized_cached_tokens = min( + max(int(cached_prompt_tokens), 0), normalized_prompt_tokens + ) + uncached_prompt_tokens = normalized_prompt_tokens - normalized_cached_tokens + + return ( + uncached_prompt_tokens * tier.input_cost_per_token + + normalized_cached_tokens * tier.cache_hit_cost_per_token + + normalized_completion_tokens * tier.output_cost_per_token ) diff --git a/backend/src/core/agent/infrastructure/litellm/usage_tracker.py b/backend/src/core/agent/infrastructure/litellm/usage_tracker.py index 0dcab94..1080dbe 100644 --- a/backend/src/core/agent/infrastructure/litellm/usage_tracker.py +++ b/backend/src/core/agent/infrastructure/litellm/usage_tracker.py @@ -3,8 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from litellm import completion_cost - from core.agent.infrastructure.litellm.pricing import calculate_tiered_model_cost @@ -26,25 +24,19 @@ def extract_usage_and_cost(response: dict[str, Any]) -> UsageCost: completion_tokens = int(usage.get("completion_tokens", 0)) total_tokens = int(usage.get("total_tokens", prompt_tokens + completion_tokens)) model_name = str(response.get("model", "")).strip().lower() + prompt_tokens_details = usage.get("prompt_tokens_details") + cached_prompt_tokens = 0 + if isinstance(prompt_tokens_details, dict): + cached_prompt_tokens = int(prompt_tokens_details.get("cached_tokens", 0) or 0) - try: - cost = completion_cost(completion_response=response) - if cost is None: - raise ValueError("unable to calculate litellm completion cost") - return UsageCost( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total_tokens, - cost=float(cost), - ) - except Exception as exc: - local_cost = calculate_tiered_model_cost( - model_name=model_name, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - if local_cost is None: - raise ValueError("unable to calculate litellm completion cost") from exc + local_cost = calculate_tiered_model_cost( + model_name=model_name, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cached_prompt_tokens=cached_prompt_tokens, + ) + if local_cost is None: + raise ValueError("unable to calculate custom completion cost") return UsageCost( prompt_tokens=prompt_tokens, diff --git a/backend/tests/unit/core/agent/test_litellm_usage.py b/backend/tests/unit/core/agent/test_litellm_usage.py index 75a15e9..804fb4c 100644 --- a/backend/tests/unit/core/agent/test_litellm_usage.py +++ b/backend/tests/unit/core/agent/test_litellm_usage.py @@ -5,15 +5,14 @@ import pytest from core.agent.infrastructure.litellm.usage_tracker import extract_usage_and_cost -def test_usage_tracker_extracts_tokens_and_cost( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "core.agent.infrastructure.litellm.usage_tracker.completion_cost", - lambda completion_response: 0.123, - ) +def test_usage_tracker_uses_custom_pricing_for_qwen35() -> None: response = { - "usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}, + "model": "dashscope/qwen3.5-flash", + "usage": { + "prompt_tokens": 11, + "completion_tokens": 7, + "total_tokens": 18, + }, } usage = extract_usage_and_cost(response) @@ -21,7 +20,8 @@ def test_usage_tracker_extracts_tokens_and_cost( assert usage.prompt_tokens == 11 assert usage.completion_tokens == 7 assert usage.total_tokens == 18 - assert usage.cost == 0.123 + assert usage.cost == pytest.approx(0.0000162) + assert usage.cost_source == "custom_pricing" @pytest.mark.parametrize( @@ -33,19 +33,10 @@ def test_usage_tracker_extracts_tokens_and_cost( ], ) def test_usage_tracker_falls_back_to_local_qwen35_pricing_when_model_unmapped( - monkeypatch: pytest.MonkeyPatch, prompt_tokens: int, completion_tokens: int, expected_cost: float, ) -> None: - def _raise_unmapped(*, completion_response): # type: ignore[no-untyped-def] - del completion_response - raise Exception("This model isn't mapped yet") - - monkeypatch.setattr( - "core.agent.infrastructure.litellm.usage_tracker.completion_cost", - _raise_unmapped, - ) response = { "model": "dashscope/qwen3.5-flash", "usage": { @@ -59,3 +50,22 @@ def test_usage_tracker_falls_back_to_local_qwen35_pricing_when_model_unmapped( assert usage.cost == pytest.approx(expected_cost) assert usage.cost_source == "custom_pricing" + + +def test_usage_tracker_uses_cached_pricing_for_deepseek_chat() -> None: + response = { + "model": "deepseek/deepseek-chat", + "usage": { + "prompt_tokens": 1_000_000, + "completion_tokens": 100_000, + "total_tokens": 1_100_000, + "prompt_tokens_details": { + "cached_tokens": 400_000, + }, + }, + } + + usage = extract_usage_and_cost(response) + + assert usage.cost == pytest.approx(1.58) + assert usage.cost_source == "custom_pricing" diff --git a/backend/tests/unit/core/agent/test_run_resume_service.py b/backend/tests/unit/core/agent/test_run_resume_service.py index 769df14..6ebc51f 100644 --- a/backend/tests/unit/core/agent/test_run_resume_service.py +++ b/backend/tests/unit/core/agent/test_run_resume_service.py @@ -1058,6 +1058,128 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result( assert runtime_state["status"] == AgentChatSessionStatus.COMPLETED +@pytest.mark.asyncio +async def test_run_service_does_not_persist_model_code_for_user_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = uuid4() + user_id = uuid4() + captured: dict[str, object] = {} + message_calls: list[dict[str, object]] = [] + + class _FakeDbSession: + async def commit(self) -> None: + return None + + class _FakeSessionFactory: + def __call__(self) -> "_FakeSessionFactory": + return self + + async def __aenter__(self) -> _FakeDbSession: + return _FakeDbSession() + + async def __aexit__(self, exc_type, exc, tb) -> bool: + del exc_type, exc, tb + return False + + class _FakeSessionRepository: + def __init__(self, session: object) -> None: + del session + + async def lock_session_for_update(self, *, session_id: object): + return SimpleNamespace( + id=session_id, + user_id=user_id, + status=AgentChatSessionStatus.PENDING, + message_count=0, + total_tokens=0, + total_cost=0, + state_snapshot=None, + ) + + async def next_message_seq(self, *, session_id: object): + del session_id + return 1 + + async def update_runtime_state(self, **kwargs) -> None: + captured["update_runtime_state"] = kwargs + + class _FakeMessageRepository: + def __init__(self, session: object) -> None: + del session + + async def append_message(self, **kwargs) -> None: + message_calls.append(kwargs) + + class _FakeRuntime: + def execute( + self, + *, + user_input: str, + system_prompt: str | None = None, + tools: list[dict[str, object]] | None = None, + ): + del user_input, system_prompt, tools + return { + "assistant_text": "ok", + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + "cost": "0.001", + "agui_events": [], + } + + async def _fake_load_agent_model_selection(self, _session): + del self + return ("qwen3.5-flash", "dashscope", SystemAgentLLMConfig()) + + async def _fake_load_user_agent_context(self, session, session_id, user_id): + del self, session, session_id + return SimpleNamespace( + user_id=user_id, + username="demo-user", + bio=None, + settings=SimpleNamespace( + preferences=SimpleNamespace( + interface_language="zh-CN", + ai_language="zh-CN", + timezone="Asia/Shanghai", + country="CN", + ) + ), + ) + + monkeypatch.setattr( + "core.agent.application.run_service.SessionRepository", + _FakeSessionRepository, + ) + monkeypatch.setattr( + "core.agent.application.run_service.MessageRepository", + _FakeMessageRepository, + ) + monkeypatch.setattr( + "core.agent.application.run_service.create_runtime", + lambda **_kwargs: _FakeRuntime(), + ) + monkeypatch.setattr( + "core.agent.application.run_service.RunService._load_agent_model_selection", + _fake_load_agent_model_selection, + ) + monkeypatch.setattr( + "core.agent.application.run_service.RunService._load_user_agent_context", + _fake_load_user_agent_context, + ) + + service = RunService(session_factory=_FakeSessionFactory()) # type: ignore[arg-type] + await service.run( + run_input=_build_run_input(thread_id=str(session_id), text="hello") + ) + + user_message = message_calls[0] + assert user_message["role"] == AgentChatMessageRole.USER + assert "model_code" not in user_message + + @pytest.mark.asyncio async def test_load_user_agent_context_parses_profile_settings_v1() -> None: session_id = uuid4() diff --git a/backend/tests/unit/core/agent/test_runtime_stage_runner_usage.py b/backend/tests/unit/core/agent/test_runtime_stage_runner_usage.py new file mode 100644 index 0000000..0d4e593 --- /dev/null +++ b/backend/tests/unit/core/agent/test_runtime_stage_runner_usage.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from core.agent.infrastructure.crewai.runtime_stage_runner import ( + LiteLLMUsageCaptureCallback, + extract_usage_from_captured_payload, + extract_usage_from_crew_output, +) + + +def test_extract_usage_from_crew_output_uses_custom_deepseek_pricing() -> None: + output = SimpleNamespace( + token_usage=SimpleNamespace( + prompt_tokens=1_000_000, + completion_tokens=100_000, + total_tokens=1_100_000, + cached_prompt_tokens=400_000, + ) + ) + + usage = extract_usage_from_crew_output( + output=output, + model="deepseek/deepseek-chat", + ) + + assert usage.prompt_tokens == 1_000_000 + assert usage.completion_tokens == 100_000 + assert usage.total_tokens == 1_100_000 + assert usage.cost == pytest.approx(1.58) + + +def test_extract_usage_from_captured_payload_uses_custom_pricing() -> None: + usage = extract_usage_from_captured_payload( + captured_usage={ + "prompt_tokens": 1_000_000, + "completion_tokens": 100_000, + "total_tokens": 1_100_000, + "prompt_tokens_details": {"cached_tokens": 400_000}, + }, + model="deepseek/deepseek-chat", + ) + + assert usage.prompt_tokens == 1_000_000 + assert usage.completion_tokens == 100_000 + assert usage.total_tokens == 1_100_000 + assert usage.cost == pytest.approx(1.58) + + +def test_usage_capture_callback_extracts_nested_usage_payload() -> None: + callback = LiteLLMUsageCaptureCallback() + + callback.log_success_event( + kwargs={}, + response_obj={ + "usage": { + "prompt_tokens": 15, + "completion_tokens": 9, + "total_tokens": 24, + } + }, + start_time=0, + end_time=0, + ) + + assert callback.captured_usage == { + "prompt_tokens": 15, + "completion_tokens": 9, + "total_tokens": 24, + }