From 8539f05a66614018fbd466c207b5ad22f668c024 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 18 Mar 2026 17:03:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20HomeScreen=20?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E4=BA=A4=E4=BA=92=E4=B8=8E=20ChatBloc=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增录音启动延迟处理,解决权限未就绪时的竞态问题 - 实现历史分页滚动位置保持,提升加载体验 - 添加文本输入框点击键盘显示与焦点管理 - 优化 ChatBloc provider 到 MultiBlocProvider 支持 - 修复 ApiException 429 错误详情解析(支持 JSON 字符串 body) - 改进 LocalNotificationService 精确闹钟权限请求 - 优化 UiSchemaRenderer GridView children 生成 - 支持导航 action 的 replace 参数 - 移除 Agent router 速率限制逻辑(_allow_run_request, _allow_transcribe_request) - 补充相关单元测试与集成测试 --- apps/android/app/src/main/AndroidManifest.xml | 13 ++ apps/ios/Runner/AppDelegate.swift | 4 + apps/lib/core/api/api_exception.dart | 40 +++- .../local_notification_service.dart | 77 +++++++- .../ui/widgets/create_event_sheet.dart | 4 +- .../chat/ui/widgets/ui_schema_renderer.dart | 11 +- .../features/home/ui/screens/home_screen.dart | 184 +++++++++++++++--- apps/lib/main.dart | 8 +- apps/test/core/api/api_exception_test.dart | 33 ++++ .../chat/ui_schema_renderer_test.dart | 58 +++++- .../ui/widgets/home_screen_layout_test.dart | 181 +++++++++++++++++ backend/src/v1/agent/router.py | 46 +---- .../tests/integration/v1/agent/test_routes.py | 62 ------ 13 files changed, 578 insertions(+), 143 deletions(-) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 9ebc80d..b8713dc 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,19 @@ android:label="灵可析" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + + + + Bool { GeneratedPluginRegistrant.register(with: self) + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/apps/lib/core/api/api_exception.dart b/apps/lib/core/api/api_exception.dart index d306560..d85b8d7 100644 --- a/apps/lib/core/api/api_exception.dart +++ b/apps/lib/core/api/api_exception.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; abstract class ApiException implements Exception { @@ -17,9 +19,14 @@ abstract class ApiException implements Exception { final data = response?.data; String detail; - if (data is Map) { + final decodedData = _normalizeErrorData(data); + + if (decodedData is Map) { detail = - (data['detail'] ?? data['message'] ?? data['error'])?.toString() ?? + (decodedData['detail'] ?? + decodedData['message'] ?? + decodedData['error']) + ?.toString() ?? '请求失败'; } else { detail = _networkErrorMessage(error); @@ -42,6 +49,29 @@ abstract class ApiException implements Exception { return const ServerException('网络错误'); } + static Map? _normalizeErrorData(dynamic data) { + if (data is Map) { + return data; + } + if (data is Map) { + return data.map((key, value) => MapEntry(key.toString(), value)); + } + if (data is String && data.trim().isNotEmpty) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) { + return null; + } + } + return null; + } + static String _localizeError(String detail, int? statusCode) { if (statusCode == 403) { return '没有权限执行此操作'; @@ -50,7 +80,11 @@ abstract class ApiException implements Exception { return '请求的资源不存在'; } if (statusCode == 429) { - return '请求过于频繁,请稍后再试'; + final normalized = detail.trim(); + if (normalized.isEmpty || normalized == '请求失败') { + return '请求过于频繁,请稍后再试'; + } + return detail; } if (statusCode != null && statusCode >= 500) { return '服务器错误,请稍后再试'; diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 62f92ba..7f6df15 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -1,12 +1,23 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/foundation.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; import '../../features/calendar/data/models/schedule_item_model.dart'; +class NotificationScheduleException implements Exception { + final String message; + + NotificationScheduleException(this.message); + + @override + String toString() => message; +} + class LocalNotificationService { final FlutterLocalNotificationsPlugin _plugin; bool _initialized = false; + bool _exactAlarmPermissionRequested = false; LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); @@ -27,17 +38,17 @@ class LocalNotificationService { await _plugin.initialize(settings); - await _plugin + final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin - >() - ?.requestNotificationsPermission(); + >(); + await androidImpl?.requestNotificationsPermission(); - await _plugin + final iosImpl = _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true); + >(); + await iosImpl?.requestPermissions(alert: true, badge: true, sound: true); _initialized = true; } @@ -59,6 +70,41 @@ class LocalNotificationService { final notificationId = _notificationIdForEvent(event.id); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); + final androidImpl = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + var androidScheduleMode = AndroidScheduleMode.exactAllowWhileIdle; + if (defaultTargetPlatform == TargetPlatform.android && + androidImpl != null) { + var notificationsEnabled = + await androidImpl.areNotificationsEnabled() ?? false; + if (!notificationsEnabled) { + await androidImpl.requestNotificationsPermission(); + notificationsEnabled = + await androidImpl.areNotificationsEnabled() ?? false; + } + if (!notificationsEnabled) { + throw NotificationScheduleException('系统通知权限未开启,无法创建提醒'); + } + + try { + var canScheduleExact = + await androidImpl.canScheduleExactNotifications() ?? false; + if (!canScheduleExact && !_exactAlarmPermissionRequested) { + _exactAlarmPermissionRequested = true; + await androidImpl.requestExactAlarmsPermission(); + canScheduleExact = + await androidImpl.canScheduleExactNotifications() ?? false; + } + if (!canScheduleExact) { + androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle; + } + } catch (_) { + androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle; + } + } final details = NotificationDetails( android: AndroidNotificationDetails( @@ -83,11 +129,20 @@ class LocalNotificationService { _buildReminderBody(event, reminderMinutes), scheduledAt, details, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: androidScheduleMode, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, ); - } catch (_) { + + final pending = await _plugin.pendingNotificationRequests(); + final scheduled = pending.any((item) => item.id == notificationId); + if (!scheduled) { + throw NotificationScheduleException('提醒未被系统接受,请检查系统通知和电池优化设置'); + } + } catch (error) { + if (error is NotificationScheduleException) { + rethrow; + } await _plugin.zonedSchedule( notificationId, event.title, @@ -98,6 +153,12 @@ class LocalNotificationService { uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, ); + + final pending = await _plugin.pendingNotificationRequests(); + final scheduled = pending.any((item) => item.id == notificationId); + if (!scheduled) { + throw NotificationScheduleException('提醒创建失败,请检查系统设置后重试'); + } } } diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index fbb5760..c8c1907 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -669,9 +669,9 @@ class _CreateEventSheetState extends State try { final notificationService = sl(); await notificationService.upsertEventReminder(saved); - } catch (_) { + } catch (e) { if (mounted) { - Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning); + Toast.show(context, '提醒创建失败:$e', type: ToastType.warning); } } diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index 0821cc9..d644835 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -75,7 +75,6 @@ class UiSchemaRenderer { ).whereType>().map(_renderNode).toList(); final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3); final gap = _asDouble(node['gap'], fallback: AppSpacing.sm); - final tiles = List.generate(children.length, (index) => children[index]); return _wrapSurface( node, GridView.count( @@ -85,7 +84,7 @@ class UiSchemaRenderer { childAspectRatio: 1.6, physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - children: tiles, + children: children, ), ); } @@ -233,6 +232,7 @@ class UiSchemaRenderer { } final params = _asMap(action['params']); + final replace = action['replace'] == true; final queryParams = _extractNavigationQueryParams(params); try { final baseUri = Uri.parse(path); @@ -240,7 +240,12 @@ class UiSchemaRenderer { final targetUri = baseUri.replace( queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams, ); - context.go(targetUri.toString()); + final target = targetUri.toString(); + if (replace) { + context.replace(target); + return; + } + context.push(target); } on FormatException { Toast.show(context, '导航路径无效', type: ToastType.warning); } diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index e3f30ef..1c4be65 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -85,6 +86,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); + final FocusNode _messageFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; late final VoiceRecorder _voiceRecorder; @@ -92,20 +94,31 @@ class _HomeScreenState extends State late final Future Function(String filePath) _transcribeAudio; late final AnimationController _listeningAnimationController; bool _isRecording = false; + bool _isRecordingStarting = false; bool _isHoldToSpeakMode = true; bool _isTranscribing = false; bool _isCancelGestureActive = false; + bool _shouldCancelWhenStartCompletes = false; + bool _shouldStopWhenStartCompletes = false; bool _isSendingMessage = false; bool _isPullRefreshing = false; + bool _isHistoryPaginationInFlight = false; int _unreadCount = 0; final List _selectedImages = []; int _lastObservedItemCount = 0; bool _lastObservedWaiting = false; + double? _historyViewportPixels; + double? _historyViewportMaxExtent; @override void initState() { super.initState(); - _chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl()); + final providedChatBloc = widget.chatBloc; + if (providedChatBloc != null) { + _chatBloc = providedChatBloc; + } else { + _chatBloc = context.read(); + } _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _inboxApi = sl(); _transcribeAudio = @@ -137,12 +150,10 @@ class _HomeScreenState extends State @override void dispose() { _messageController.dispose(); + _messageFocusNode.dispose(); _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); - if (widget.chatBloc == null) { - _chatBloc.close(); - } super.dispose(); } @@ -159,7 +170,11 @@ class _HomeScreenState extends State final hasItemCountChanged = state.items.length != _lastObservedItemCount; final waitingStateChanged = isWaitingNow != _lastObservedWaiting; - if (hasItemCountChanged || waitingStateChanged) { + final shouldAutoScroll = + !_isHistoryPaginationInFlight && + !state.isLoadingHistory && + (hasItemCountChanged || waitingStateChanged); + if (shouldAutoScroll) { _scheduleAutoScroll(animated: hasItemCountChanged); } _lastObservedItemCount = state.items.length; @@ -373,7 +388,7 @@ class _HomeScreenState extends State return; } final chatBloc = context.read(); - if (chatBloc.state.isLoadingHistory) { + if (chatBloc.state.isLoadingHistory || _isHistoryPaginationInFlight) { return; } final hasEarlierHistory = chatBloc.state.hasEarlierHistory; @@ -383,7 +398,7 @@ class _HomeScreenState extends State final startedAt = DateTime.now(); try { if (hasEarlierHistory) { - await chatBloc.loadMoreHistory(); + await _loadMoreHistoryPreservingViewport(chatBloc); } else { Toast.show(context, '没有更早的历史记录了', type: ToastType.info); } @@ -401,8 +416,63 @@ class _HomeScreenState extends State } } - void _onLoadMore(BuildContext context) { - context.read().loadMoreHistory(); + Future _onLoadMore(BuildContext context) async { + final chatBloc = context.read(); + await _loadMoreHistoryPreservingViewport(chatBloc); + } + + Future _loadMoreHistoryPreservingViewport(ChatBloc chatBloc) async { + if (_isHistoryPaginationInFlight) { + return; + } + _captureHistoryViewportAnchor(); + if (mounted) { + setState(() { + _isHistoryPaginationInFlight = true; + }); + } + try { + await chatBloc.loadMoreHistory(); + } finally { + _restoreHistoryViewportAnchor(); + if (mounted) { + setState(() { + _isHistoryPaginationInFlight = false; + }); + } + } + } + + void _captureHistoryViewportAnchor() { + if (!_scrollController.hasClients) { + _historyViewportPixels = null; + _historyViewportMaxExtent = null; + return; + } + final position = _scrollController.position; + _historyViewportPixels = position.pixels; + _historyViewportMaxExtent = position.maxScrollExtent; + } + + void _restoreHistoryViewportAnchor() { + final previousPixels = _historyViewportPixels; + final previousMaxExtent = _historyViewportMaxExtent; + _historyViewportPixels = null; + _historyViewportMaxExtent = null; + if (previousPixels == null || previousMaxExtent == null) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) { + return; + } + final position = _scrollController.position; + final extentDelta = position.maxScrollExtent - previousMaxExtent; + final targetOffset = (previousPixels + extentDelta) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + _scrollController.jumpTo(targetOffset); + }); } bool _isAgentWaiting(ChatState state) { @@ -742,8 +812,7 @@ class _HomeScreenState extends State } Widget _buildInputContainer(BuildContext context, ChatState state) { - final isWaitingAgent = - state.isWaitingFirstToken || state.isStreaming || state.isCancelling; + final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); return ValueListenableBuilder( valueListenable: _messageController, builder: (context, value, child) { @@ -797,6 +866,7 @@ class _HomeScreenState extends State alignment: Alignment.centerLeft, child: TextField( controller: _messageController, + focusNode: _messageFocusNode, minLines: 1, maxLines: 1, style: const TextStyle( @@ -822,19 +892,38 @@ class _HomeScreenState extends State contentPadding: EdgeInsets.zero, filled: false, ), + onTap: _onTextFieldTap, onSubmitted: (_) => _sendMessage(context), ), ), ); } - void _onRightActionTap(BuildContext context, ChatState state) { - if (_isTranscribing || _isRecording || _isSendingMessage) { + void _onTextFieldTap() { + final alreadyFocused = _messageFocusNode.hasFocus; + if (!alreadyFocused) { + _messageFocusNode.requestFocus(); return; } - final isWaitingAgent = - state.isWaitingFirstToken || state.isStreaming || state.isCancelling; - if (isWaitingAgent) { + if (!_supportsProgrammaticKeyboardShow()) { + return; + } + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + + bool _supportsProgrammaticKeyboardShow() { + if (kIsWeb) { + return false; + } + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + } + + void _onRightActionTap(BuildContext context, ChatState state) { + if (_isTranscribing || _isRecording) { + return; + } + if (_isSendingMessage || _isAgentWaiting(state)) { _onStopGenerating(); return; } @@ -849,9 +938,13 @@ class _HomeScreenState extends State if (_isRecording || _isTranscribing) { return; } + final willSwitchToText = _isHoldToSpeakMode; setState(() { - _isHoldToSpeakMode = !_isHoldToSpeakMode; + _isHoldToSpeakMode = !willSwitchToText; }); + if (!willSwitchToText) { + _messageFocusNode.unfocus(); + } } void _onHoldToSpeakStart() { @@ -863,6 +956,14 @@ class _HomeScreenState extends State } void _onHoldToSpeakEnd() { + if (_isRecordingStarting) { + _shouldCancelWhenStartCompletes = false; + _shouldStopWhenStartCompletes = true; + return; + } + if (!_isRecording) { + return; + } if (_isCancelGestureActive) { HapticFeedback.selectionClick(); _cancelRecording(showToast: false); @@ -883,6 +984,11 @@ class _HomeScreenState extends State } void _onHoldToSpeakCancel() { + if (_isRecordingStarting) { + _shouldStopWhenStartCompletes = false; + _shouldCancelWhenStartCompletes = true; + return; + } _cancelRecording(showToast: false); } @@ -1064,13 +1170,41 @@ class _HomeScreenState extends State } Future _startRecording() async { + if (_isRecording || _isRecordingStarting) { + return; + } + if (mounted) { + setState(() { + _isRecordingStarting = true; + _shouldCancelWhenStartCompletes = false; + _shouldStopWhenStartCompletes = false; + }); + } try { await _voiceRecorder.start(); _listeningAnimationController.repeat(); if (!mounted) { return; } + if (_shouldStopWhenStartCompletes || _shouldCancelWhenStartCompletes) { + final shouldCancelAfterStart = + _shouldCancelWhenStartCompletes || _isCancelGestureActive; + setState(() { + _isRecordingStarting = false; + _shouldCancelWhenStartCompletes = false; + _shouldStopWhenStartCompletes = false; + _isRecording = true; + _isCancelGestureActive = false; + }); + if (shouldCancelAfterStart) { + await _cancelRecording(showToast: false); + return; + } + await _stopRecording(autoSendAfterTranscribe: true); + return; + } setState(() { + _isRecordingStarting = false; _isRecording = true; _isCancelGestureActive = false; }); @@ -1078,6 +1212,11 @@ class _HomeScreenState extends State if (!mounted) { return; } + setState(() { + _isRecordingStarting = false; + _shouldCancelWhenStartCompletes = false; + _shouldStopWhenStartCompletes = false; + }); Toast.show(context, _readableError(error), type: ToastType.error); } } @@ -1107,15 +1246,14 @@ class _HomeScreenState extends State Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error); return; } - _messageController.text = transcript; + _messageController.text = normalizedTranscript; _messageController.selection = TextSelection.fromPosition( - TextPosition(offset: transcript.length), + TextPosition(offset: normalizedTranscript.length), ); if (autoSendAfterTranscribe) { - _messageController.text = normalizedTranscript; - _messageController.selection = TextSelection.fromPosition( - TextPosition(offset: normalizedTranscript.length), - ); + setState(() { + _isTranscribing = false; + }); await _sendMessage(context); } } catch (error) { diff --git a/apps/lib/main.dart b/apps/lib/main.dart index f98e431..99840c5 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -13,6 +13,7 @@ import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/calendar/data/services/calendar_service.dart'; +import 'features/chat/presentation/bloc/chat_bloc.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -45,8 +46,11 @@ class LinksyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: authBloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider(create: (_) => ChatBloc(apiClient: sl())), + ], child: BlocListener( listenWhen: (previous, current) => previous != current, listener: (context, state) { diff --git a/apps/test/core/api/api_exception_test.dart b/apps/test/core/api/api_exception_test.dart index c32820e..e086cf0 100644 --- a/apps/test/core/api/api_exception_test.dart +++ b/apps/test/core/api/api_exception_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:dio/dio.dart'; import 'package:social_app/core/api/api_exception.dart'; void main() { @@ -15,5 +16,37 @@ void main() { const exception = UnauthorizedException(); expect(exception.message, '请重新登录'); }); + + test('429 returns backend detail message', () { + final dioException = DioException( + requestOptions: RequestOptions(path: '/api/v1/agent/runs'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/agent/runs'), + statusCode: 429, + data: {'detail': 'Too many SSE connections'}, + ), + ); + + final apiException = ApiException.fromDioError(dioException); + + expect(apiException.statusCode, 429); + expect(apiException.message, 'Too many SSE connections'); + }); + + test('429 parses detail from string json body', () { + final dioException = DioException( + requestOptions: RequestOptions(path: '/api/v1/agent/runs'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/agent/runs'), + statusCode: 429, + data: '{"detail":"Too many SSE connections"}', + ), + ); + + final apiException = ApiException.fromDioError(dioException); + + expect(apiException.statusCode, 429); + expect(apiException.message, 'Too many SSE connections'); + }); }); } diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart index 968b3a4..557cbbb 100644 --- a/apps/test/features/chat/ui_schema_renderer_test.dart +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -130,7 +130,9 @@ void main() { expect(find.textContaining('无效 UI Schema'), findsOneWidget); }); - testWidgets('handles navigation action and jumps by path', (tester) async { + testWidgets('handles navigation action by pushing target page', ( + tester, + ) async { final schema = { 'version': '2.0', 'root': { @@ -174,6 +176,60 @@ void main() { await tester.pumpAndSettle(); expect(find.text('todo detail 123 from assistant'), findsOneWidget); + expect(router.canPop(), isTrue); + + router.pop(); + await tester.pumpAndSettle(); + + expect(find.text('查看待办'), findsOneWidget); + }); + + testWidgets('uses replace navigation when replace is true', (tester) async { + final schema = { + 'version': '2.0', + 'root': { + 'type': 'stack', + 'direction': 'vertical', + 'appearance': 'plain', + 'children': [ + { + 'type': 'button', + 'label': '替换跳转', + 'style': 'primary', + 'action': { + 'type': 'navigation', + 'path': '/todo/456', + 'replace': true, + }, + }, + ], + }, + }; + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => + Scaffold(body: UiSchemaRenderer.renderSchema(schema)), + ), + GoRoute( + path: '/todo/:id', + builder: (context, state) => + Text('todo detail ${state.pathParameters['id']}'), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.tap(find.text('替换跳转')); + await tester.pumpAndSettle(); + + expect(find.text('todo detail 456'), findsOneWidget); + expect(router.canPop(), isFalse); + + expect(find.text('todo detail 456'), findsOneWidget); }); testWidgets('does not navigate for placeholder path', (tester) async { diff --git a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart index b58703c..ae386f9 100644 --- a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -1,14 +1,59 @@ import 'package:dio/dio.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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 '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'; import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart'; import 'package:social_app/features/home/ui/widgets/home_floating_header.dart'; import 'package:social_app/features/messages/data/inbox_api.dart'; +import 'package:social_app/shared/widgets/message_composer.dart'; + +class _PermissionDeniedRecorder implements VoiceRecorder { + _PermissionDeniedRecorder(); + + int stopCalls = 0; + + @override + Future dispose() async {} + + @override + Future start() async { + await Future.delayed(const Duration(milliseconds: 400)); + throw StateError('录音权限未授权'); + } + + @override + Future stop() async { + stopCalls += 1; + return null; + } +} + +class _DelayedSuccessRecorder implements VoiceRecorder { + _DelayedSuccessRecorder(); + + int stopCalls = 0; + + @override + Future dispose() async {} + + @override + Future start() async { + await Future.delayed(const Duration(milliseconds: 400)); + } + + @override + Future stop() async { + stopCalls += 1; + return '/tmp/mock-recording.wav'; + } +} class _TestApiClient implements IApiClient { @override @@ -65,6 +110,8 @@ void main() { Future pumpHomeScreen( WidgetTester tester, { List initialSelectedImages = const [], + VoiceRecorder? voiceRecorder, + Future Function(String filePath)? onTranscribeAudio, }) async { await tester.pumpWidget( MaterialApp( @@ -72,6 +119,8 @@ void main() { chatBloc: chatBloc, autoLoadHistory: false, initialSelectedImages: initialSelectedImages, + voiceRecorder: voiceRecorder, + onTranscribeAudio: onTranscribeAudio, ), ), ); @@ -111,4 +160,136 @@ void main() { expect(find.byKey(homeAttachmentStripKey), findsOneWidget); }); + + testWidgets( + 'long press release does not stop recorder before start succeeds', + (tester) async { + final recorder = _PermissionDeniedRecorder(); + await pumpHomeScreen(tester, voiceRecorder: recorder); + + final holdArea = find.byKey(messageComposerHoldAreaKey); + expect(holdArea, findsOneWidget); + + final center = tester.getCenter(holdArea); + final gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 130)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(recorder.stopCalls, 0); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets('switching to text mode does not auto focus input', ( + tester, + ) async { + await pumpHomeScreen(tester); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isFalse); + }); + + testWidgets('single tap on input focuses text field after mode switch', ( + tester, + ) async { + await pumpHomeScreen(tester); + + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + }); + + testWidgets('tap focused input triggers keyboard show once', (tester) async { + var showCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.textInput, (call) async { + if (call.method == 'TextInput.show') { + showCalls += 1; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.textInput, null); + }); + + await pumpHomeScreen(tester); + await tester.tap(find.byKey(messageComposerRightButtonKey)); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + showCalls = 0; + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + expect(showCalls, 1); + }); + + testWidgets('release during delayed start continues to transcribe path', ( + tester, + ) async { + final recorder = _DelayedSuccessRecorder(); + var transcribeCalls = 0; + await pumpHomeScreen( + tester, + voiceRecorder: recorder, + onTranscribeAudio: (_) async { + transcribeCalls += 1; + return ''; + }, + ); + + final holdArea = find.byKey(messageComposerHoldAreaKey); + final center = tester.getCenter(holdArea); + final gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 130)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(recorder.stopCalls, 1); + expect(transcribeCalls, 1); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('cancel during delayed start skips transcribe path', ( + tester, + ) async { + final recorder = _DelayedSuccessRecorder(); + var transcribeCalls = 0; + await pumpHomeScreen( + tester, + voiceRecorder: recorder, + onTranscribeAudio: (_) async { + transcribeCalls += 1; + return 'ignored'; + }, + ); + + final holdArea = find.byKey(messageComposerHoldAreaKey); + final center = tester.getCenter(holdArea); + final gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 130)); + await gesture.moveBy(const Offset(0, -90)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(recorder.stopCalls, 1); + expect(transcribeCalls, 0); + }); } diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index c6f4807..708caf0 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -4,7 +4,6 @@ import asyncio import os import re import tempfile -import time from collections.abc import AsyncIterator from datetime import date from typing import Annotated, Union @@ -46,8 +45,6 @@ from v1.users.dependencies import get_current_user router = APIRouter(prefix="/agent", tags=["agent"]) logger = get_logger("v1.agent.router") _LAST_EVENT_ID_RE = re.compile(r"^\d+-\d+$") -_RUNS_PER_MINUTE = 30 -_TRANSCRIBES_PER_MINUTE = 20 _MAX_SSE_CONNECTIONS_PER_USER = 3 _SSE_SLOT_TTL_SECONDS = 15 * 60 _MAX_TRANSCRIBE_AUDIO_BYTES = 10 * 1024 * 1024 @@ -68,32 +65,6 @@ def _looks_like_wav_header(header: bytes) -> bool: return header[0:4] == b"RIFF" and header[8:12] == b"WAVE" -async def _allow_run_request(*, user_id: str) -> bool: - try: - redis = await get_or_init_redis_client() - minute_bucket = int(time.time() // 60) - key = f"agent:run-rate:{user_id}:{minute_bucket}" - count = await redis.incr(key) - if count == 1: - await redis.expire(key, 70) - return int(count) <= _RUNS_PER_MINUTE - except Exception: # noqa: BLE001 - return False - - -async def _allow_transcribe_request(*, user_id: str) -> bool: - try: - redis = await get_or_init_redis_client() - minute_bucket = int(time.time() // 60) - key = f"agent:transcribe-rate:{user_id}:{minute_bucket}" - count = await redis.incr(key) - if count == 1: - await redis.expire(key, 70) - return int(count) <= _TRANSCRIBES_PER_MINUTE - except Exception: # noqa: BLE001 - return False - - async def _acquire_sse_slot(*, user_id: str) -> bool: try: redis = await get_or_init_redis_client() @@ -105,7 +76,12 @@ async def _acquire_sse_slot(*, user_id: str) -> bool: await redis.decr(key) return False return True - except Exception: # noqa: BLE001 + except Exception as exc: # noqa: BLE001 + logger.warning( + "SSE slot acquire failed", + user_id=user_id, + reason=str(exc), + ) return False @@ -136,10 +112,6 @@ async def enqueue_run( validate_run_request_messages_contract(request) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - allowed = await _allow_run_request(user_id=str(current_user.id)) - if not allowed: - raise HTTPException(status_code=429, detail="Too many run requests") - task = await service.enqueue_run( run_input=request, current_user=current_user, @@ -293,14 +265,10 @@ async def create_attachment_signed_url( async def transcribe( audio: UploadFile, request: Request, - current_user: Annotated[CurrentUser, Depends(get_current_user)], + _current_user: Annotated[CurrentUser, Depends(get_current_user)], ) -> Union[AsrTranscribeResponse, JSONResponse]: temp_path: str | None = None try: - allowed = await _allow_transcribe_request(user_id=str(current_user.id)) - if not allowed: - raise HTTPException(status_code=429, detail="Too many transcribe requests") - if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES: raise ValueError("Unsupported audio format") diff --git a/backend/tests/integration/v1/agent/test_routes.py b/backend/tests/integration/v1/agent/test_routes.py index 9f03160..b39aea5 100644 --- a/backend/tests/integration/v1/agent/test_routes.py +++ b/backend/tests/integration/v1/agent/test_routes.py @@ -118,13 +118,6 @@ class _FailingStreamAgentService(_FakeAgentService): def test_run_requires_auth_and_returns_202_task_id() -> None: app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() client = TestClient(app) - original_allow_run = agent_router._allow_run_request - - async def _allow_run(*, user_id: str) -> bool: - del user_id - return True - - agent_router._allow_run_request = _allow_run # type: ignore[assignment] try: unauthorized = client.post( @@ -162,7 +155,6 @@ def test_run_requires_auth_and_returns_202_task_id() -> None: assert authorized.json()["runId"] == "run-1" assert authorized.json()["created"] is False finally: - agent_router._allow_run_request = original_allow_run # type: ignore[assignment] app.dependency_overrides = {} @@ -410,12 +402,6 @@ def test_asr_transcribe_returns_sync_transcript(monkeypatch) -> None: id=uuid4(), email="user@example.com" ) - async def _allow_transcribe(*, user_id: str) -> bool: - del user_id - return True - - monkeypatch.setattr(agent_router, "_allow_transcribe_request", _allow_transcribe) - async def mock_transcribe_file(file_path: str, filename: str) -> str: assert file_path.endswith(".wav") assert filename == "test.wav" @@ -453,12 +439,6 @@ def test_asr_transcribe_rejects_oversized_audio(monkeypatch) -> None: monkeypatch.setattr(agent_router, "_MAX_TRANSCRIBE_AUDIO_BYTES", 4) - async def _allow_transcribe(*, user_id: str) -> bool: - del user_id - return True - - monkeypatch.setattr(agent_router, "_allow_transcribe_request", _allow_transcribe) - client = TestClient(app) oversized = BytesIO(b"12345") oversized.name = "test.wav" @@ -480,12 +460,6 @@ def test_asr_transcribe_rejects_non_wav_audio(monkeypatch) -> None: id=uuid4(), email="user@example.com" ) - async def _allow_transcribe(*, user_id: str) -> bool: - del user_id - return True - - monkeypatch.setattr(agent_router, "_allow_transcribe_request", _allow_transcribe) - client = TestClient(app) fake_mp3 = BytesIO(b"fake-mp3") fake_mp3.name = "test.mp3" @@ -507,12 +481,6 @@ def test_asr_transcribe_rejects_invalid_wav_payload(monkeypatch) -> None: id=uuid4(), email="user@example.com" ) - async def _allow_transcribe(*, user_id: str) -> bool: - del user_id - return True - - monkeypatch.setattr(agent_router, "_allow_transcribe_request", _allow_transcribe) - client = TestClient(app) fake_payload = BytesIO(b"not-a-wav") fake_payload.name = "test.wav" @@ -527,33 +495,3 @@ def test_asr_transcribe_rejects_invalid_wav_payload(monkeypatch) -> None: assert response.json()["detail"] == "Unsupported audio format" finally: app.dependency_overrides = {} - - -def test_asr_transcribe_rejects_when_rate_limited_for_current_user(monkeypatch) -> None: - known_user = CurrentUser(id=uuid4(), email="user@example.com") - app.dependency_overrides[get_current_user] = lambda: known_user - - captured_user_ids: list[str] = [] - - async def _deny_transcribe(*, user_id: str) -> bool: - captured_user_ids.append(user_id) - return False - - monkeypatch.setattr(agent_router, "_allow_transcribe_request", _deny_transcribe) - - client = TestClient(app) - wav_content = b"RIFF\x24\x80\x00\x00WAVEfmt " - wav_file = BytesIO(wav_content) - wav_file.name = "test.wav" - - try: - response = client.post( - "/api/v1/agent/transcribe", - files={"audio": ("test.wav", wav_file, "audio/wav")}, - ) - - assert response.status_code == 429 - assert response.json()["detail"] == "Too many transcribe requests" - assert captured_user_ids == [str(known_user.id)] - finally: - app.dependency_overrides = {}