feat: 增强 HomeScreen 录音交互与 ChatBloc 状态管理

- 新增录音启动延迟处理,解决权限未就绪时的竞态问题
- 实现历史分页滚动位置保持,提升加载体验
- 添加文本输入框点击键盘显示与焦点管理
- 优化 ChatBloc provider 到 MultiBlocProvider 支持
- 修复 ApiException 429 错误详情解析(支持 JSON 字符串 body)
- 改进 LocalNotificationService 精确闹钟权限请求
- 优化 UiSchemaRenderer GridView children 生成
- 支持导航 action 的 replace 参数
- 移除 Agent router 速率限制逻辑(_allow_run_request, _allow_transcribe_request)
- 补充相关单元测试与集成测试
This commit is contained in:
qzl
2026-03-18 17:03:22 +08:00
parent b34697660d
commit 8539f05a66
13 changed files with 578 additions and 143 deletions
@@ -15,6 +15,19 @@
android:label="灵可析" android:label="灵可析"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver
android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+4
View File
@@ -1,5 +1,6 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,9 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }
+37 -3
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
abstract class ApiException implements Exception { abstract class ApiException implements Exception {
@@ -17,9 +19,14 @@ abstract class ApiException implements Exception {
final data = response?.data; final data = response?.data;
String detail; String detail;
if (data is Map<String, dynamic>) { final decodedData = _normalizeErrorData(data);
if (decodedData is Map<String, dynamic>) {
detail = detail =
(data['detail'] ?? data['message'] ?? data['error'])?.toString() ?? (decodedData['detail'] ??
decodedData['message'] ??
decodedData['error'])
?.toString() ??
'请求失败'; '请求失败';
} else { } else {
detail = _networkErrorMessage(error); detail = _networkErrorMessage(error);
@@ -42,6 +49,29 @@ abstract class ApiException implements Exception {
return const ServerException('网络错误'); return const ServerException('网络错误');
} }
static Map<String, dynamic>? _normalizeErrorData(dynamic data) {
if (data is Map<String, dynamic>) {
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<String, dynamic>) {
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) { static String _localizeError(String detail, int? statusCode) {
if (statusCode == 403) { if (statusCode == 403) {
return '没有权限执行此操作'; return '没有权限执行此操作';
@@ -50,7 +80,11 @@ abstract class ApiException implements Exception {
return '请求的资源不存在'; return '请求的资源不存在';
} }
if (statusCode == 429) { if (statusCode == 429) {
return '请求过于频繁,请稍后再试'; final normalized = detail.trim();
if (normalized.isEmpty || normalized == '请求失败') {
return '请求过于频繁,请稍后再试';
}
return detail;
} }
if (statusCode != null && statusCode >= 500) { if (statusCode != null && statusCode >= 500) {
return '服务器错误,请稍后再试'; return '服务器错误,请稍后再试';
@@ -1,12 +1,23 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 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/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import '../../features/calendar/data/models/schedule_item_model.dart'; 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 { class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin; final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false; bool _initialized = false;
bool _exactAlarmPermissionRequested = false;
LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) LocalNotificationService({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin(); : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
@@ -27,17 +38,17 @@ class LocalNotificationService {
await _plugin.initialize(settings); await _plugin.initialize(settings);
await _plugin final androidImpl = _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin
>() >();
?.requestNotificationsPermission(); await androidImpl?.requestNotificationsPermission();
await _plugin final iosImpl = _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin IOSFlutterLocalNotificationsPlugin
>() >();
?.requestPermissions(alert: true, badge: true, sound: true); await iosImpl?.requestPermissions(alert: true, badge: true, sound: true);
_initialized = true; _initialized = true;
} }
@@ -59,6 +70,41 @@ class LocalNotificationService {
final notificationId = _notificationIdForEvent(event.id); final notificationId = _notificationIdForEvent(event.id);
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); 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( final details = NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
@@ -83,11 +129,20 @@ class LocalNotificationService {
_buildReminderBody(event, reminderMinutes), _buildReminderBody(event, reminderMinutes),
scheduledAt, scheduledAt,
details, details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, androidScheduleMode: androidScheduleMode,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, 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( await _plugin.zonedSchedule(
notificationId, notificationId,
event.title, event.title,
@@ -98,6 +153,12 @@ class LocalNotificationService {
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
); );
final pending = await _plugin.pendingNotificationRequests();
final scheduled = pending.any((item) => item.id == notificationId);
if (!scheduled) {
throw NotificationScheduleException('提醒创建失败,请检查系统设置后重试');
}
} }
} }
@@ -669,9 +669,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
try { try {
final notificationService = sl<LocalNotificationService>(); final notificationService = sl<LocalNotificationService>();
await notificationService.upsertEventReminder(saved); await notificationService.upsertEventReminder(saved);
} catch (_) { } catch (e) {
if (mounted) { if (mounted) {
Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning); Toast.show(context, '提醒创建失败$e', type: ToastType.warning);
} }
} }
@@ -75,7 +75,6 @@ class UiSchemaRenderer {
).whereType<Map<String, dynamic>>().map(_renderNode).toList(); ).whereType<Map<String, dynamic>>().map(_renderNode).toList();
final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3); final columns = _asInt(node['columns'], fallback: 2).clamp(1, 3);
final gap = _asDouble(node['gap'], fallback: AppSpacing.sm); final gap = _asDouble(node['gap'], fallback: AppSpacing.sm);
final tiles = List.generate(children.length, (index) => children[index]);
return _wrapSurface( return _wrapSurface(
node, node,
GridView.count( GridView.count(
@@ -85,7 +84,7 @@ class UiSchemaRenderer {
childAspectRatio: 1.6, childAspectRatio: 1.6,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
children: tiles, children: children,
), ),
); );
} }
@@ -233,6 +232,7 @@ class UiSchemaRenderer {
} }
final params = _asMap(action['params']); final params = _asMap(action['params']);
final replace = action['replace'] == true;
final queryParams = _extractNavigationQueryParams(params); final queryParams = _extractNavigationQueryParams(params);
try { try {
final baseUri = Uri.parse(path); final baseUri = Uri.parse(path);
@@ -240,7 +240,12 @@ class UiSchemaRenderer {
final targetUri = baseUri.replace( final targetUri = baseUri.replace(
queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams, queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams,
); );
context.go(targetUri.toString()); final target = targetUri.toString();
if (replace) {
context.replace(target);
return;
}
context.push(target);
} on FormatException { } on FormatException {
Toast.show(context, '导航路径无效', type: ToastType.warning); Toast.show(context, '导航路径无效', type: ToastType.warning);
} }
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -85,6 +86,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
final FocusNode _messageFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc; late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder; late final VoiceRecorder _voiceRecorder;
@@ -92,20 +94,31 @@ class _HomeScreenState extends State<HomeScreen>
late final Future<String> Function(String filePath) _transcribeAudio; late final Future<String> Function(String filePath) _transcribeAudio;
late final AnimationController _listeningAnimationController; late final AnimationController _listeningAnimationController;
bool _isRecording = false; bool _isRecording = false;
bool _isRecordingStarting = false;
bool _isHoldToSpeakMode = true; bool _isHoldToSpeakMode = true;
bool _isTranscribing = false; bool _isTranscribing = false;
bool _isCancelGestureActive = false; bool _isCancelGestureActive = false;
bool _shouldCancelWhenStartCompletes = false;
bool _shouldStopWhenStartCompletes = false;
bool _isSendingMessage = false; bool _isSendingMessage = false;
bool _isPullRefreshing = false; bool _isPullRefreshing = false;
bool _isHistoryPaginationInFlight = false;
int _unreadCount = 0; int _unreadCount = 0;
final List<XFile> _selectedImages = []; final List<XFile> _selectedImages = [];
int _lastObservedItemCount = 0; int _lastObservedItemCount = 0;
bool _lastObservedWaiting = false; bool _lastObservedWaiting = false;
double? _historyViewportPixels;
double? _historyViewportMaxExtent;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl()); final providedChatBloc = widget.chatBloc;
if (providedChatBloc != null) {
_chatBloc = providedChatBloc;
} else {
_chatBloc = context.read<ChatBloc>();
}
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_inboxApi = sl<InboxApi>(); _inboxApi = sl<InboxApi>();
_transcribeAudio = _transcribeAudio =
@@ -137,12 +150,10 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void dispose() { void dispose() {
_messageController.dispose(); _messageController.dispose();
_messageFocusNode.dispose();
_scrollController.dispose(); _scrollController.dispose();
_listeningAnimationController.dispose(); _listeningAnimationController.dispose();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
if (widget.chatBloc == null) {
_chatBloc.close();
}
super.dispose(); super.dispose();
} }
@@ -159,7 +170,11 @@ class _HomeScreenState extends State<HomeScreen>
final hasItemCountChanged = final hasItemCountChanged =
state.items.length != _lastObservedItemCount; state.items.length != _lastObservedItemCount;
final waitingStateChanged = isWaitingNow != _lastObservedWaiting; final waitingStateChanged = isWaitingNow != _lastObservedWaiting;
if (hasItemCountChanged || waitingStateChanged) { final shouldAutoScroll =
!_isHistoryPaginationInFlight &&
!state.isLoadingHistory &&
(hasItemCountChanged || waitingStateChanged);
if (shouldAutoScroll) {
_scheduleAutoScroll(animated: hasItemCountChanged); _scheduleAutoScroll(animated: hasItemCountChanged);
} }
_lastObservedItemCount = state.items.length; _lastObservedItemCount = state.items.length;
@@ -373,7 +388,7 @@ class _HomeScreenState extends State<HomeScreen>
return; return;
} }
final chatBloc = context.read<ChatBloc>(); final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.isLoadingHistory) { if (chatBloc.state.isLoadingHistory || _isHistoryPaginationInFlight) {
return; return;
} }
final hasEarlierHistory = chatBloc.state.hasEarlierHistory; final hasEarlierHistory = chatBloc.state.hasEarlierHistory;
@@ -383,7 +398,7 @@ class _HomeScreenState extends State<HomeScreen>
final startedAt = DateTime.now(); final startedAt = DateTime.now();
try { try {
if (hasEarlierHistory) { if (hasEarlierHistory) {
await chatBloc.loadMoreHistory(); await _loadMoreHistoryPreservingViewport(chatBloc);
} else { } else {
Toast.show(context, '没有更早的历史记录了', type: ToastType.info); Toast.show(context, '没有更早的历史记录了', type: ToastType.info);
} }
@@ -401,8 +416,63 @@ class _HomeScreenState extends State<HomeScreen>
} }
} }
void _onLoadMore(BuildContext context) { Future<void> _onLoadMore(BuildContext context) async {
context.read<ChatBloc>().loadMoreHistory(); final chatBloc = context.read<ChatBloc>();
await _loadMoreHistoryPreservingViewport(chatBloc);
}
Future<void> _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) { bool _isAgentWaiting(ChatState state) {
@@ -742,8 +812,7 @@ class _HomeScreenState extends State<HomeScreen>
} }
Widget _buildInputContainer(BuildContext context, ChatState state) { Widget _buildInputContainer(BuildContext context, ChatState state) {
final isWaitingAgent = final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state);
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return ValueListenableBuilder<TextEditingValue>( return ValueListenableBuilder<TextEditingValue>(
valueListenable: _messageController, valueListenable: _messageController,
builder: (context, value, child) { builder: (context, value, child) {
@@ -797,6 +866,7 @@ class _HomeScreenState extends State<HomeScreen>
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextField( child: TextField(
controller: _messageController, controller: _messageController,
focusNode: _messageFocusNode,
minLines: 1, minLines: 1,
maxLines: 1, maxLines: 1,
style: const TextStyle( style: const TextStyle(
@@ -822,19 +892,38 @@ class _HomeScreenState extends State<HomeScreen>
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
filled: false, filled: false,
), ),
onTap: _onTextFieldTap,
onSubmitted: (_) => _sendMessage(context), onSubmitted: (_) => _sendMessage(context),
), ),
), ),
); );
} }
void _onRightActionTap(BuildContext context, ChatState state) { void _onTextFieldTap() {
if (_isTranscribing || _isRecording || _isSendingMessage) { final alreadyFocused = _messageFocusNode.hasFocus;
if (!alreadyFocused) {
_messageFocusNode.requestFocus();
return; return;
} }
final isWaitingAgent = if (!_supportsProgrammaticKeyboardShow()) {
state.isWaitingFirstToken || state.isStreaming || state.isCancelling; return;
if (isWaitingAgent) { }
SystemChannels.textInput.invokeMethod<void>('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(); _onStopGenerating();
return; return;
} }
@@ -849,9 +938,13 @@ class _HomeScreenState extends State<HomeScreen>
if (_isRecording || _isTranscribing) { if (_isRecording || _isTranscribing) {
return; return;
} }
final willSwitchToText = _isHoldToSpeakMode;
setState(() { setState(() {
_isHoldToSpeakMode = !_isHoldToSpeakMode; _isHoldToSpeakMode = !willSwitchToText;
}); });
if (!willSwitchToText) {
_messageFocusNode.unfocus();
}
} }
void _onHoldToSpeakStart() { void _onHoldToSpeakStart() {
@@ -863,6 +956,14 @@ class _HomeScreenState extends State<HomeScreen>
} }
void _onHoldToSpeakEnd() { void _onHoldToSpeakEnd() {
if (_isRecordingStarting) {
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = true;
return;
}
if (!_isRecording) {
return;
}
if (_isCancelGestureActive) { if (_isCancelGestureActive) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_cancelRecording(showToast: false); _cancelRecording(showToast: false);
@@ -883,6 +984,11 @@ class _HomeScreenState extends State<HomeScreen>
} }
void _onHoldToSpeakCancel() { void _onHoldToSpeakCancel() {
if (_isRecordingStarting) {
_shouldStopWhenStartCompletes = false;
_shouldCancelWhenStartCompletes = true;
return;
}
_cancelRecording(showToast: false); _cancelRecording(showToast: false);
} }
@@ -1064,13 +1170,41 @@ class _HomeScreenState extends State<HomeScreen>
} }
Future<void> _startRecording() async { Future<void> _startRecording() async {
if (_isRecording || _isRecordingStarting) {
return;
}
if (mounted) {
setState(() {
_isRecordingStarting = true;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
}
try { try {
await _voiceRecorder.start(); await _voiceRecorder.start();
_listeningAnimationController.repeat(); _listeningAnimationController.repeat();
if (!mounted) { if (!mounted) {
return; 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(() { setState(() {
_isRecordingStarting = false;
_isRecording = true; _isRecording = true;
_isCancelGestureActive = false; _isCancelGestureActive = false;
}); });
@@ -1078,6 +1212,11 @@ class _HomeScreenState extends State<HomeScreen>
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() {
_isRecordingStarting = false;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
Toast.show(context, _readableError(error), type: ToastType.error); Toast.show(context, _readableError(error), type: ToastType.error);
} }
} }
@@ -1107,15 +1246,14 @@ class _HomeScreenState extends State<HomeScreen>
Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error); Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error);
return; return;
} }
_messageController.text = transcript; _messageController.text = normalizedTranscript;
_messageController.selection = TextSelection.fromPosition( _messageController.selection = TextSelection.fromPosition(
TextPosition(offset: transcript.length), TextPosition(offset: normalizedTranscript.length),
); );
if (autoSendAfterTranscribe) { if (autoSendAfterTranscribe) {
_messageController.text = normalizedTranscript; setState(() {
_messageController.selection = TextSelection.fromPosition( _isTranscribing = false;
TextPosition(offset: normalizedTranscript.length), });
);
await _sendMessage(context); await _sendMessage(context);
} }
} catch (error) { } catch (error) {
+6 -2
View File
@@ -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_event.dart';
import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/auth/presentation/bloc/auth_state.dart';
import 'features/calendar/data/services/calendar_service.dart'; import 'features/calendar/data/services/calendar_service.dart';
import 'features/chat/presentation/bloc/chat_bloc.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -45,8 +46,11 @@ class LinksyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<AuthBloc>.value( return MultiBlocProvider(
value: authBloc, providers: [
BlocProvider<AuthBloc>.value(value: authBloc),
BlocProvider<ChatBloc>(create: (_) => ChatBloc(apiClient: sl())),
],
child: BlocListener<AuthBloc, AuthState>( child: BlocListener<AuthBloc, AuthState>(
listenWhen: (previous, current) => previous != current, listenWhen: (previous, current) => previous != current,
listener: (context, state) { listener: (context, state) {
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.dart';
import 'package:social_app/core/api/api_exception.dart'; import 'package:social_app/core/api/api_exception.dart';
void main() { void main() {
@@ -15,5 +16,37 @@ void main() {
const exception = UnauthorizedException(); const exception = UnauthorizedException();
expect(exception.message, '请重新登录'); expect(exception.message, '请重新登录');
}); });
test('429 returns backend detail message', () {
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
response: Response<dynamic>(
requestOptions: RequestOptions(path: '/api/v1/agent/runs'),
statusCode: 429,
data: <String, dynamic>{'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<dynamic>(
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');
});
}); });
} }
@@ -130,7 +130,9 @@ void main() {
expect(find.textContaining('无效 UI Schema'), findsOneWidget); 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 = { final schema = {
'version': '2.0', 'version': '2.0',
'root': { 'root': {
@@ -174,6 +176,60 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('todo detail 123 from assistant'), findsOneWidget); 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 { testWidgets('does not navigate for placeholder path', (tester) async {
@@ -1,14 +1,59 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/di/injection.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/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/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_attachment_strip.dart';
import 'package:social_app/features/home/ui/widgets/home_floating_header.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/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<void> dispose() async {}
@override
Future<void> start() async {
await Future<void>.delayed(const Duration(milliseconds: 400));
throw StateError('录音权限未授权');
}
@override
Future<String?> stop() async {
stopCalls += 1;
return null;
}
}
class _DelayedSuccessRecorder implements VoiceRecorder {
_DelayedSuccessRecorder();
int stopCalls = 0;
@override
Future<void> dispose() async {}
@override
Future<void> start() async {
await Future<void>.delayed(const Duration(milliseconds: 400));
}
@override
Future<String?> stop() async {
stopCalls += 1;
return '/tmp/mock-recording.wav';
}
}
class _TestApiClient implements IApiClient { class _TestApiClient implements IApiClient {
@override @override
@@ -65,6 +110,8 @@ void main() {
Future<void> pumpHomeScreen( Future<void> pumpHomeScreen(
WidgetTester tester, { WidgetTester tester, {
List<XFile> initialSelectedImages = const [], List<XFile> initialSelectedImages = const [],
VoiceRecorder? voiceRecorder,
Future<String> Function(String filePath)? onTranscribeAudio,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -72,6 +119,8 @@ void main() {
chatBloc: chatBloc, chatBloc: chatBloc,
autoLoadHistory: false, autoLoadHistory: false,
initialSelectedImages: initialSelectedImages, initialSelectedImages: initialSelectedImages,
voiceRecorder: voiceRecorder,
onTranscribeAudio: onTranscribeAudio,
), ),
), ),
); );
@@ -111,4 +160,136 @@ void main() {
expect(find.byKey(homeAttachmentStripKey), findsOneWidget); 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<EditableText>(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<EditableText>(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);
});
} }
+7 -39
View File
@@ -4,7 +4,6 @@ import asyncio
import os import os
import re import re
import tempfile import tempfile
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import date from datetime import date
from typing import Annotated, Union from typing import Annotated, Union
@@ -46,8 +45,6 @@ from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/agent", tags=["agent"]) router = APIRouter(prefix="/agent", tags=["agent"])
logger = get_logger("v1.agent.router") logger = get_logger("v1.agent.router")
_LAST_EVENT_ID_RE = re.compile(r"^\d+-\d+$") _LAST_EVENT_ID_RE = re.compile(r"^\d+-\d+$")
_RUNS_PER_MINUTE = 30
_TRANSCRIBES_PER_MINUTE = 20
_MAX_SSE_CONNECTIONS_PER_USER = 3 _MAX_SSE_CONNECTIONS_PER_USER = 3
_SSE_SLOT_TTL_SECONDS = 15 * 60 _SSE_SLOT_TTL_SECONDS = 15 * 60
_MAX_TRANSCRIBE_AUDIO_BYTES = 10 * 1024 * 1024 _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" 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: async def _acquire_sse_slot(*, user_id: str) -> bool:
try: try:
redis = await get_or_init_redis_client() redis = await get_or_init_redis_client()
@@ -105,7 +76,12 @@ async def _acquire_sse_slot(*, user_id: str) -> bool:
await redis.decr(key) await redis.decr(key)
return False return False
return True 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 return False
@@ -136,10 +112,6 @@ async def enqueue_run(
validate_run_request_messages_contract(request) validate_run_request_messages_contract(request)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from 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( task = await service.enqueue_run(
run_input=request, run_input=request,
current_user=current_user, current_user=current_user,
@@ -293,14 +265,10 @@ async def create_attachment_signed_url(
async def transcribe( async def transcribe(
audio: UploadFile, audio: UploadFile,
request: Request, request: Request,
current_user: Annotated[CurrentUser, Depends(get_current_user)], _current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> Union[AsrTranscribeResponse, JSONResponse]: ) -> Union[AsrTranscribeResponse, JSONResponse]:
temp_path: str | None = None temp_path: str | None = None
try: 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: if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES:
raise ValueError("Unsupported audio format") raise ValueError("Unsupported audio format")
@@ -118,13 +118,6 @@ class _FailingStreamAgentService(_FakeAgentService):
def test_run_requires_auth_and_returns_202_task_id() -> None: def test_run_requires_auth_and_returns_202_task_id() -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
client = TestClient(app) 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: try:
unauthorized = client.post( 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()["runId"] == "run-1"
assert authorized.json()["created"] is False assert authorized.json()["created"] is False
finally: finally:
agent_router._allow_run_request = original_allow_run # type: ignore[assignment]
app.dependency_overrides = {} app.dependency_overrides = {}
@@ -410,12 +402,6 @@ def test_asr_transcribe_returns_sync_transcript(monkeypatch) -> None:
id=uuid4(), email="user@example.com" 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: async def mock_transcribe_file(file_path: str, filename: str) -> str:
assert file_path.endswith(".wav") assert file_path.endswith(".wav")
assert filename == "test.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) 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) client = TestClient(app)
oversized = BytesIO(b"12345") oversized = BytesIO(b"12345")
oversized.name = "test.wav" oversized.name = "test.wav"
@@ -480,12 +460,6 @@ def test_asr_transcribe_rejects_non_wav_audio(monkeypatch) -> None:
id=uuid4(), email="user@example.com" 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) client = TestClient(app)
fake_mp3 = BytesIO(b"fake-mp3") fake_mp3 = BytesIO(b"fake-mp3")
fake_mp3.name = "test.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" 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) client = TestClient(app)
fake_payload = BytesIO(b"not-a-wav") fake_payload = BytesIO(b"not-a-wav")
fake_payload.name = "test.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" assert response.json()["detail"] == "Unsupported audio format"
finally: finally:
app.dependency_overrides = {} 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 = {}