From 3ac09475ad227a99e6dc139e71113e781f3e3bd7 Mon Sep 17 00:00:00 2001 From: zl-q Date: Mon, 9 Mar 2026 00:10:09 +0800 Subject: [PATCH] feat(agent): add voice input capability and standardize tool naming - Add voice recording with transcribe endpoint (ASR) for multimodal input - Android: add RECORD_AUDIO and INTERNET permissions - Refactor tool naming: frontend tools use 'front.' prefix, backend tools use 'back.' - Migrate calendar tools: create_calendar_event -> back.mutate/list/delete events - Add calendar_event_list.v1 and calendar_operation.v1 UI card types - Update all Flutter and Python tests to match new tool naming conventions - Add record package dependency for voice recording --- apps/android/app/src/main/AndroidManifest.xml | 3 + .../chat/data/ai/ai_decision_engine.dart | 4 +- .../chat/data/models/ag_ui_event.dart | 8 + .../chat/data/services/ag_ui_service.dart | 198 ++++++------- .../chat/data/tools/tool_registry.dart | 59 +--- .../chat/presentation/bloc/chat_bloc.dart | 35 ++- .../chat/ui/widgets/ui_schema_renderer.dart | 106 ++++++- .../features/home/data/voice_recorder.dart | 64 ++++ .../features/home/ui/screens/home_screen.dart | 234 +++++++++++++-- apps/pubspec.yaml | 1 + apps/test/features/chat/ag_ui_event_test.dart | 35 ++- .../features/chat/ag_ui_service_test.dart | 72 ++--- .../chat/ai_decision_engine_test.dart | 17 +- apps/test/features/chat/chat_bloc_test.dart | 41 ++- .../features/chat/tool_registry_test.dart | 93 ++---- .../chat/ui_schema_renderer_test.dart | 55 ++++ .../home/ui/screens/home_screen_test.dart | 138 ++++++++- .../agent/infrastructure/crewai/runtime.py | 44 ++- .../infrastructure/crewai/tools/__init__.py | 6 +- .../tools/create_calendar_event_tool.py | 273 ++++++++++++++++-- .../crewai/tools/stage_tool_allowlist.py | 5 +- backend/src/v1/schedule_items/repository.py | 50 +++- backend/src/v1/schedule_items/service.py | 28 ++ backend/tests/e2e/test_agent_live_flow.py | 2 +- .../agent/test_create_calendar_event_tool.py | 65 ----- .../unit/core/agent/test_crewai_runtime.py | 77 ++++- .../agent/test_list_calendar_events_tool.py | 128 ++++++++ .../agent/test_mutate_calendar_event_tool.py | 173 +++++++++++ .../core/agent/test_run_resume_service.py | 12 +- .../core/agent/test_stage_tool_allowlist.py | 5 +- 30 files changed, 1593 insertions(+), 438 deletions(-) create mode 100644 apps/lib/features/home/data/voice_recorder.dart delete mode 100644 backend/tests/unit/core/agent/test_create_calendar_event_tool.py create mode 100644 backend/tests/unit/core/agent/test_list_calendar_events_tool.py create mode 100644 backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 515ce09..e0f2ff5 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + ) { return UiCard.fromJson(rawUi); } + final rawResult = payload['result']; + if (rawResult is Map) { + final type = rawResult['type']; + final data = rawResult['data']; + if (type is String && data is Map) { + return UiCard.fromJson(rawResult); + } + } return null; } 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 82dd9b3..7d7798d 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'package:dio/dio.dart'; import 'package:social_app/core/api/i_api_client.dart'; import 'package:social_app/core/api/mock_api_client.dart'; import '../ai/ai_decision_engine.dart'; import '../models/ag_ui_event.dart'; -import '../models/tool_result.dart'; import '../tools/tool_registry.dart'; import 'mock_history_service.dart'; @@ -36,7 +36,7 @@ class AgUiService { _decisionEngine = AiDecisionEngine(), _historyService = MockHistoryService() { if (_apiClient is MockApiClient) { - _configureMockAgentApi(_apiClient as MockApiClient); + _configureMockAgentApi(_apiClient); } } @@ -77,6 +77,28 @@ class AgUiService { onEvent(event); } + Future transcribeAudio(String filePath) async { + final formData = FormData.fromMap({ + 'audio': await MultipartFile.fromFile( + filePath, + filename: 'recording.wav', + ), + }); + final response = await _apiClient.post>( + '/api/v1/agent/transcribe', + data: formData, + ); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /agent/transcribe response'); + } + final transcript = payload['transcript']; + if (transcript is! String) { + throw StateError('Missing transcript in /agent/transcribe response'); + } + return transcript; + } + Future approveToolCall({ required String toolCallId, required String toolName, @@ -210,11 +232,7 @@ class AgUiService { 'runId': runId, 'state': {}, 'messages': [ - { - 'id': _nextId('user_'), - 'role': 'user', - 'content': content, - }, + {'id': _nextId('user_'), 'role': 'user', 'content': content}, ], 'tools': _buildTools(), 'context': >[], @@ -225,33 +243,20 @@ class AgUiService { List> _buildTools() { return [ { - 'name': 'navigate_to_route', + 'name': 'front.navigate_to_route', 'description': 'Navigate user to a route in the mobile app.', 'parameters': { 'type': 'object', 'properties': { 'target': {'type': 'string', 'description': 'Route path target'}, - 'replace': {'type': 'boolean', 'description': 'Use replace navigation'}, + 'replace': { + 'type': 'boolean', + 'description': 'Use replace navigation', + }, }, 'required': ['target'], }, }, - { - 'name': 'create_calendar_event', - 'description': 'Create a calendar schedule event.', - 'parameters': { - 'type': 'object', - 'properties': { - 'title': {'type': 'string'}, - 'description': {'type': 'string'}, - 'startAt': {'type': 'string', 'format': 'date-time'}, - 'endAt': {'type': 'string', 'format': 'date-time'}, - 'timezone': {'type': 'string'}, - 'location': {'type': 'string'}, - }, - 'required': ['title', 'startAt'], - }, - }, ]; } @@ -270,7 +275,8 @@ class AgUiService { return '/api/v1/agent/history?${query.join('&')}'; } - String _nextId(String prefix) => '$prefix${DateTime.now().millisecondsSinceEpoch}'; + String _nextId(String prefix) => + '$prefix${DateTime.now().millisecondsSinceEpoch}'; String _newUuid() { final random = Random(); @@ -304,6 +310,15 @@ class AgUiService { 'SSE', _handleMockSse, ); + client.registerHandler( + '/api/v1/agent/transcribe', + 'POST', + _handleMockTranscribe, + ); + } + + Map _handleMockTranscribe(MockRequest request) { + return {'transcript': '这是模拟语音转写'}; } Map _handleMockRun(MockRequest request) { @@ -331,9 +346,9 @@ class AgUiService { } Map _handleMockResume(MockRequest request) { - final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/resume$').firstMatch( - request.path, - ); + final match = RegExp( + r'^/api/v1/agent/runs/([^/]+)/resume$', + ).firstMatch(request.path); final threadId = match?.group(1) ?? (_threadId ?? _newUuid()); final payload = request.data; final runInput = payload is Map @@ -344,7 +359,11 @@ class AgUiService { final toolMessage = _extractLatestToolMessage(runInput); final events = >[ - {'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId}, + { + 'type': AgUiEventTypeWire.runStarted, + 'threadId': threadId, + 'runId': runId, + }, { 'type': AgUiEventTypeWire.toolCallResult, 'messageId': _nextId(_messageIdPrefix), @@ -365,7 +384,11 @@ class AgUiService { 'type': AgUiEventTypeWire.textMessageEnd, 'messageId': _nextId(_messageIdPrefix), }, - {'type': AgUiEventTypeWire.runFinished, 'threadId': threadId, 'runId': runId}, + { + 'type': AgUiEventTypeWire.runFinished, + 'threadId': threadId, + 'runId': runId, + }, ]; _mockSseLinesByThread[threadId] = _toSseLines(events); return { @@ -398,7 +421,8 @@ class AgUiService { final messages = targetDate == null ? [] : _historyService.getHistoryForDay(targetDate); - final hasMore = targetDate != null && _historyService.hasEarlierHistory(targetDate); + final hasMore = + targetDate != null && _historyService.hasEarlierHistory(targetDate); _hasMoreHistory = hasMore; return { @@ -421,9 +445,9 @@ class AgUiService { } Stream _handleMockSse(MockRequest request) { - final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/events$').firstMatch( - request.path, - ); + final match = RegExp( + r'^/api/v1/agent/runs/([^/]+)/events$', + ).firstMatch(request.path); final threadId = match?.group(1); if (threadId == null) { return const Stream.empty(); @@ -441,7 +465,11 @@ class AgUiService { required String userInput, }) { final events = >[ - {'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId}, + { + 'type': AgUiEventTypeWire.runStarted, + 'threadId': threadId, + 'runId': runId, + }, ]; final forceTrigger = _decisionEngine.tryForceTrigger(userInput); @@ -451,19 +479,13 @@ class AgUiService { toolName = forceTrigger.toolName; args = forceTrigger.args; } else if (_looksLikeNavigationIntent(userInput)) { - toolName = 'navigate_to_route'; + toolName = 'front.navigate_to_route'; args = {'target': _inferNavigationRoute(userInput), 'replace': false}; - } else if (_decisionEngine.shouldTriggerToolCall(userInput)) { - toolName = 'create_calendar_event'; - args = _decisionEngine.getToolCallArgs(userInput); } if (toolName != null && args != null) { - if (toolName == 'navigate_to_route') { - args = { - ...args, - '__nonce': _nextId('nonce_'), - }; + if (toolName == 'front.navigate_to_route') { + args = {...args, '__nonce': _nextId('nonce_')}; } final toolCallId = _nextId(_toolCallIdPrefix); events.add({ @@ -476,32 +498,20 @@ class AgUiService { 'toolCallId': toolCallId, 'delta': jsonEncode(args), }); - events.add({'type': AgUiEventTypeWire.toolCallEnd, 'toolCallId': toolCallId}); + events.add({ + 'type': AgUiEventTypeWire.toolCallEnd, + 'toolCallId': toolCallId, + }); - if (toolName == 'navigate_to_route') { + if (toolName == 'front.navigate_to_route') { // 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。 } else { - final validation = ToolRegistry.validateArgs(toolName, args); - if (!validation.ok) { - events.add({ - 'type': AgUiEventTypeWire.toolCallError, - 'toolCallId': toolCallId, - 'error': validation.error ?? 'Validation failed', - 'code': 'VALIDATION_ERROR', - }); - } else { - final result = _mockCalendarResult(args); - final ui = _buildUiCard(toolName, result); - events.add({ - 'type': AgUiEventTypeWire.toolCallResult, - 'messageId': _nextId(_messageIdPrefix), - 'toolCallId': toolCallId, - 'content': jsonEncode({ - 'result': result, - if (ui != null) 'ui': ui.toJson(), - }), - }); - } + events.add({ + 'type': AgUiEventTypeWire.toolCallError, + 'toolCallId': toolCallId, + 'error': 'Unsupported frontend tool in mock mode', + 'code': 'UNSUPPORTED_TOOL', + }); } } @@ -518,7 +528,10 @@ class AgUiService { 'messageId': messageId, 'delta': reply, }); - events.add({'type': AgUiEventTypeWire.textMessageEnd, 'messageId': messageId}); + events.add({ + 'type': AgUiEventTypeWire.textMessageEnd, + 'messageId': messageId, + }); } events.add({ @@ -577,57 +590,14 @@ class AgUiService { if (raw['role'] != 'tool') { continue; } - final toolCallId = raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix); + final toolCallId = + raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix); final content = raw['content'] as String? ?? '{}'; return (toolCallId, content); } return (_nextId(_toolCallIdPrefix), '{}'); } - Map _mockCalendarResult(Map args) { - final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}'; - return { - 'eventId': eventId, - 'ok': true, - 'message': '日程已创建', - 'title': args['title'], - 'description': args['description'], - 'startAt': args['startAt'], - 'endAt': args['endAt'], - 'timezone': args['timezone'] ?? 'Asia/Shanghai', - 'location': args['location'], - 'color': '#4F46E5', - 'sourceType': 'agentGenerated', - }; - } - - UiCard? _buildUiCard(String toolName, Map result) { - if (toolName != 'create_calendar_event') { - return null; - } - return UiCard( - cardType: 'calendar_card.v1', - data: CalendarCardData( - id: result['eventId'] ?? '', - title: result['title'] ?? '', - description: result['description'], - startAt: result['startAt'] ?? '', - endAt: result['endAt'], - timezone: result['timezone'], - location: result['location'], - color: result['color'], - sourceType: result['sourceType'], - ).toJson(), - actions: [ - CardAction( - type: 'link', - label: '查看详情', - target: '/calendar/events/${result['eventId']}', - ), - ], - ); - } - List _generateReplies(String content) { final intent = _decisionEngine.matchIntent(content); switch (intent) { diff --git a/apps/lib/features/chat/data/tools/tool_registry.dart b/apps/lib/features/chat/data/tools/tool_registry.dart index d37576a..7a566ca 100644 --- a/apps/lib/features/chat/data/tools/tool_registry.dart +++ b/apps/lib/features/chat/data/tools/tool_registry.dart @@ -4,13 +4,7 @@ typedef ToolHandler = Future> Function(Map args); /// 工具常量 -const _toolNameCreateCalendar = 'create_calendar_event'; -const _toolNameNavigateRoute = 'navigate_to_route'; -const _defaultTimezone = 'Asia/Shanghai'; -const _defaultEventColor = '#4F46E5'; -const _defaultSourceType = 'agentGenerated'; -const _titleMinLength = 1; -const _titleMaxLength = 100; +const _toolNameNavigateRoute = 'front.navigate_to_route'; class ToolDefinition { final String name; @@ -33,38 +27,6 @@ class ToolRegistry { static void initialize() { if (_initialized) return; - _tools[_toolNameCreateCalendar] = ToolDefinition( - name: _toolNameCreateCalendar, - description: '创建一个日历事件或待办事项', - parameters: { - 'type': 'object', - 'properties': { - 'title': { - 'type': 'string', - 'description': '事件标题', - 'minLength': _titleMinLength, - 'maxLength': _titleMaxLength, - }, - 'description': {'type': 'string', 'description': '事件描述'}, - 'startAt': { - 'type': 'string', - 'format': 'date-time', - 'description': '开始时间 (ISO8601)', - }, - 'endAt': { - 'type': 'string', - 'format': 'date-time', - 'description': '结束时间 (ISO8601)', - }, - 'timezone': {'type': 'string', 'default': _defaultTimezone}, - 'location': {'type': 'string'}, - 'notes': {'type': 'string'}, - }, - 'required': ['title', 'startAt'], - }, - handler: _handleCreateCalendarEvent, - ); - _tools[_toolNameNavigateRoute] = ToolDefinition( name: _toolNameNavigateRoute, description: '在前端执行路由跳转', @@ -82,25 +44,6 @@ class ToolRegistry { _initialized = true; } - static Future> _handleCreateCalendarEvent( - Map args, - ) async { - final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}'; - return { - 'eventId': eventId, - 'ok': true, - 'message': '日程已创建', - 'title': args['title'], - 'description': args['description'], - 'startAt': args['startAt'], - 'endAt': args['endAt'], - 'timezone': args['timezone'] ?? _defaultTimezone, - 'location': args['location'], - 'color': _defaultEventColor, - 'sourceType': _defaultSourceType, - }; - } - static Future> _handleNavigateRoute( Map args, ) async { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 95dd0ab..7c59430 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -2,11 +2,11 @@ import 'dart:convert'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/di/injection.dart'; import '../../data/models/ag_ui_event.dart'; import '../../data/models/chat_list_item.dart'; -import '../../data/models/tool_result.dart'; import '../../data/services/ag_ui_service.dart'; class ChatState { @@ -57,7 +57,14 @@ class ChatBloc extends Cubit { ChatBloc({AgUiService? service, IApiClient? apiClient}) : _service = - service ?? AgUiService(apiClient: apiClient ?? sl()), + service ?? + AgUiService( + apiClient: + apiClient ?? + (sl.isRegistered() + ? sl() + : MockApiClient()), + ), super(const ChatState()) { _service.onEvent = _handleEvent; } @@ -162,13 +169,10 @@ class ChatBloc extends Cubit { _toolCallArgsBuffer.remove(endEvent.toolCallId); final updatedItems = state.items.map((item) { if (item.id == endEvent.toolCallId && item is ToolCallItem) { - final nextStatus = item.toolName == 'navigate_to_route' + final nextStatus = item.toolName == 'front.navigate_to_route' ? ToolCallStatus.pending : ToolCallStatus.executing; - return item.copyWith( - args: parsedArgs, - status: nextStatus, - ); + return item.copyWith(args: parsedArgs, status: nextStatus); } return item; }).toList(); @@ -344,7 +348,10 @@ class ChatBloc extends Cubit { } final updatedItems = state.items.map((item) { if (item is ToolCallItem && item.callId == toolCallId) { - return item.copyWith(status: ToolCallStatus.executing, errorMessage: null); + return item.copyWith( + status: ToolCallStatus.executing, + errorMessage: null, + ); } return item; }).toList(); @@ -365,10 +372,20 @@ class ChatBloc extends Cubit { } return item; }).toList(); - emit(state.copyWith(items: failedItems, isLoading: false, error: error.toString())); + emit( + state.copyWith( + items: failedItems, + isLoading: false, + error: error.toString(), + ), + ); } } + Future transcribeAudioFile(String filePath) { + return _service.transcribeAudio(filePath); + } + void clearError() { emit(state.copyWith(error: null)); } 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 c4f1884..15e05bb 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -4,14 +4,19 @@ import '../../data/models/tool_result.dart'; /// 卡片类型常量 const _calendarCardType = 'calendar_card.v1'; +const _calendarListType = 'calendar_event_list.v1'; +const _calendarOperationType = 'calendar_operation.v1'; const _errorCardType = 'error_card.v1'; const _aiGeneratedSource = 'ai_generated'; +const _agentGeneratedSource = 'agent_generated'; const _primaryActionType = 'primary'; class UiSchemaRenderer { static Widget render(UiCard card) { return switch (card.cardType) { _calendarCardType => _renderCalendarCard(card), + _calendarListType => _renderCalendarList(card), + _calendarOperationType => _renderCalendarOperation(card), _errorCardType => _renderErrorCard(card), _ => _renderUnknownCard(card), }; @@ -22,7 +27,9 @@ class UiSchemaRenderer { final color = data.color != null ? Color(int.parse(data.color!.replaceFirst('#', '0xFF'))) : AppColors.blue500; - final isAiGenerated = data.sourceType == _aiGeneratedSource; + final isAiGenerated = + data.sourceType == _aiGeneratedSource || + data.sourceType == _agentGeneratedSource; return Container( decoration: BoxDecoration( @@ -152,6 +159,103 @@ class UiSchemaRenderer { ); } + static Widget _renderCalendarList(UiCard card) { + final rawItems = card.data['items']; + final items = rawItems is List ? rawItems : const []; + final paginationRaw = card.data['pagination']; + final pagination = paginationRaw is Map + ? paginationRaw + : const {}; + final page = pagination['page']; + final total = pagination['total']; + + return Container( + padding: EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.messageCardBg, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.messageCardBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '日程列表', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + if (page != null || total != null) ...[ + SizedBox(height: AppSpacing.xs), + Text( + '第${page ?? '-'}页 · 共${total ?? '-'}条', + style: TextStyle(fontSize: 12, color: AppColors.slate500), + ), + ], + SizedBox(height: AppSpacing.sm), + if (items.isEmpty) + Text( + '暂无日程', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + ), + for (final item in items) + if (item is Map) + Padding( + padding: EdgeInsets.only(bottom: AppSpacing.xs), + child: Text( + item['title']?.toString() ?? '未命名日程', + style: TextStyle(fontSize: 14, color: AppColors.slate700), + ), + ), + ], + ), + ); + } + + static Widget _renderCalendarOperation(UiCard card) { + final ok = card.data['ok'] == true; + final operation = card.data['operation']?.toString() ?? 'operation'; + final message = card.data['message']?.toString() ?? (ok ? '操作成功' : '操作失败'); + + return Container( + padding: EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: ok ? AppColors.messageCardBg : AppColors.warningBackground, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: ok ? AppColors.messageCardBorder : AppColors.red400, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '日程$operation结果', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ok ? AppColors.slate900 : AppColors.red600, + ), + ), + SizedBox(height: AppSpacing.xs), + Text( + message, + style: TextStyle( + fontSize: 13, + color: ok ? AppColors.slate600 : AppColors.red600, + ), + ), + if (card.actions != null && card.actions!.isNotEmpty) ...[ + SizedBox(height: AppSpacing.md), + _buildActions(card.actions!), + ], + ], + ), + ); + } + static Widget _renderErrorCard(UiCard card) { final message = card.data['message'] as String? ?? '发生错误'; diff --git a/apps/lib/features/home/data/voice_recorder.dart b/apps/lib/features/home/data/voice_recorder.dart new file mode 100644 index 0000000..1957ada --- /dev/null +++ b/apps/lib/features/home/data/voice_recorder.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:record/record.dart'; + +abstract class VoiceRecorder { + Future start(); + Future stop(); + Future dispose(); +} + +class RecordVoiceRecorder implements VoiceRecorder { + final AudioRecorder _recorder; + String? _currentPath; + + RecordVoiceRecorder({AudioRecorder? recorder}) + : _recorder = recorder ?? AudioRecorder(); + + @override + Future start() async { + bool hasPermission; + try { + hasPermission = await _recorder.hasPermission(); + } on MissingPluginException catch (_) { + throw StateError('录音组件未加载,请完全重启 App 后重试'); + } + if (!hasPermission) { + throw StateError('录音权限未授权'); + } + + final fileName = + 'voice_${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecond}.wav'; + final path = '${Directory.systemTemp.path}/$fileName'; + _currentPath = path; + try { + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 16000, + numChannels: 1, + ), + path: path, + ); + } on MissingPluginException catch (_) { + throw StateError('录音组件未加载,请完全重启 App 后重试'); + } + } + + @override + Future stop() async { + String? stoppedPath; + try { + stoppedPath = await _recorder.stop(); + } on MissingPluginException catch (_) { + throw StateError('录音组件未加载,请完全重启 App 后重试'); + } + return stoppedPath ?? _currentPath; + } + + @override + Future dispose() async { + await _recorder.dispose(); + } +} diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index e6abbbd..151ca68 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -1,11 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/api/api_exception.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../chat/data/models/chat_list_item.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../chat/data/tools/route_navigation_tool.dart'; +import '../../data/voice_recorder.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -23,22 +27,42 @@ const _cornerRadius = 12.0; const _inputMinHeight = 48.0; const _inputRadius = 24.0; const _scrollDurationMs = 300; +const _rippleDurationMs = 1200; +const _recordingDotSize = 10.0; /// 颜色常量 const _chatBgColor = Color(0xFFF8FAFC); const _userBubbleColor = Color(0xFFEAF1FB); class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); + final VoiceRecorder? voiceRecorder; + final Future Function(String filePath)? onTranscribeAudio; + final Future Function(String transcript)? onAutoSendTranscript; + final bool autoLoadHistory; + + const HomeScreen({ + super.key, + this.voiceRecorder, + this.onTranscribeAudio, + this.onAutoSendTranscript, + this.autoLoadHistory = true, + }); @override State createState() => _HomeScreenState(); } -class _HomeScreenState extends State { +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; + late final VoiceRecorder _voiceRecorder; + late final Future Function(String filePath) _transcribeAudio; + late final Future Function(String transcript) _autoSendTranscript; + late final AnimationController _listeningAnimationController; + bool _isRecording = false; + bool _isTranscribing = false; bool get _hasMessage => _messageController.text.trim().isNotEmpty; @@ -47,7 +71,17 @@ class _HomeScreenState extends State { super.initState(); _messageController.addListener(_onMessageChanged); _chatBloc = ChatBloc(); - _chatBloc.loadHistory(); + _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); + _transcribeAudio = + widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; + _autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage; + _listeningAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: _rippleDurationMs), + ); + if (widget.autoLoadHistory) { + _chatBloc.loadHistory(); + } } @override @@ -55,6 +89,8 @@ class _HomeScreenState extends State { _messageController.removeListener(_onMessageChanged); _messageController.dispose(); _scrollController.dispose(); + _listeningAnimationController.dispose(); + _voiceRecorder.dispose(); _chatBloc.close(); RouteNavigationTool.instance.clearNavigator(); super.dispose(); @@ -341,7 +377,7 @@ class _HomeScreenState extends State { ), const SizedBox(width: 8), Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), - if (item.toolName == 'navigate_to_route' && + if (item.toolName == 'front.navigate_to_route' && item.status == ToolCallStatus.pending) ...[ const SizedBox(width: 8), GestureDetector( @@ -376,7 +412,9 @@ class _HomeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( - onTap: () => _showBottomSheet(context), + onTap: _isRecording + ? _stopRecording + : () => _showBottomSheet(context), child: Container( width: 36, height: 36, @@ -385,10 +423,10 @@ class _HomeScreenState extends State { shape: BoxShape.circle, border: Border.all(color: AppColors.slate300), ), - child: const Icon( - LucideIcons.plus, + child: Icon( + _isRecording ? LucideIcons.square : LucideIcons.plus, size: 20, - color: AppColors.slate500, + color: _isRecording ? AppColors.red600 : AppColors.slate500, ), ), ), @@ -406,32 +444,42 @@ class _HomeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: TextField( - controller: _messageController, - minLines: 1, - maxLines: 3, - decoration: const InputDecoration( - hintText: '输入消息...', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - filled: false, - ), - onSubmitted: (_) => _sendMessage(context), - ), + child: _isRecording + ? _buildListeningIndicator() + : TextField( + controller: _messageController, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( + hintText: '输入消息...', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + filled: false, + ), + onSubmitted: (_) => _sendMessage(context), + ), ), const SizedBox(width: 8), GestureDetector( - onTap: _hasMessage ? () => _sendMessage(context) : null, + onTap: _isTranscribing + ? null + : _isRecording + ? () => _stopRecording(autoSendAfterTranscribe: true) + : _hasMessage + ? () => _sendMessage(context) + : _startRecording, child: Icon( - _hasMessage ? LucideIcons.send : LucideIcons.mic, + _isRecording || _hasMessage + ? LucideIcons.send + : LucideIcons.mic, size: _iconSize, - color: _hasMessage + color: _isRecording || _hasMessage ? AppColors.blue600 : AppColors.slate500, ), @@ -462,6 +510,134 @@ class _HomeScreenState extends State { }); } + Widget _buildListeningIndicator() { + return SizedBox( + height: _inputMinHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _listeningAnimationController, + builder: (context, _) { + final t = _listeningAnimationController.value; + final waveA = + 0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0)); + final waveB = + 0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0)); + final waveC = + 0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0)); + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildWaveDot(scale: waveA), + const SizedBox(width: 6), + _buildWaveDot(scale: waveB), + const SizedBox(width: 6), + _buildWaveDot(scale: waveC), + ], + ); + }, + ), + const SizedBox(width: 10), + const Text( + '正在聆听...', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + ), + ], + ), + ); + } + + Widget _buildWaveDot({required double scale}) { + return Transform.scale( + scale: scale, + child: Container( + width: _recordingDotSize, + height: _recordingDotSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.red600, + ), + ), + ); + } + + Future _startRecording() async { + try { + await _voiceRecorder.start(); + _listeningAnimationController.repeat(); + if (!mounted) { + return; + } + setState(() { + _isRecording = true; + }); + } catch (error) { + if (!mounted) { + return; + } + Toast.show(context, _readableError(error), type: ToastType.error); + } + } + + Future _stopRecording({bool autoSendAfterTranscribe = false}) async { + String? audioPath; + try { + audioPath = await _voiceRecorder.stop(); + _listeningAnimationController.stop(); + if (!mounted) { + return; + } + setState(() { + _isRecording = false; + _isTranscribing = true; + }); + if (audioPath == null || audioPath.isEmpty) { + throw StateError('录音失败,请重试'); + } + final transcript = await _transcribeAudio(audioPath); + if (!mounted) { + return; + } + _messageController.text = transcript; + _messageController.selection = TextSelection.fromPosition( + TextPosition(offset: transcript.length), + ); + if (autoSendAfterTranscribe && transcript.trim().isNotEmpty) { + _messageController.clear(); + await _autoSendTranscript(transcript.trim()); + } + } catch (error) { + if (!mounted) { + return; + } + Toast.show(context, _readableError(error), type: ToastType.error); + } finally { + if (audioPath != null) { + final file = File(audioPath); + if (await file.exists()) { + await file.delete(); + } + } + if (mounted) { + setState(() { + _isTranscribing = false; + }); + } + } + } + + String _readableError(Object error) { + if (error is ApiException) { + return error.message; + } + final raw = error.toString(); + if (raw.startsWith('Instance of')) { + return '请求失败,请稍后重试'; + } + return raw.replaceFirst('Bad state: ', ''); + } + void _showBottomSheet(BuildContext context) { showModalBottomSheet( context: context, diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 0b0826d..f0783d8 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: intl: ^0.19.0 shared_preferences: ^2.2.2 json_annotation: ^4.8.1 + record: ^6.1.1 dev_dependencies: flutter_test: diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart index 3822367..5b84be8 100644 --- a/apps/test/features/chat/ag_ui_event_test.dart +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -194,7 +194,7 @@ void main() { final json = { 'type': 'TOOL_CALL_START', 'toolCallId': 'tc_123', - 'toolCallName': 'create_calendar_event', + 'toolCallName': 'back.mutate_calendar_event', 'parentMessageId': 'msg_001', }; @@ -203,7 +203,7 @@ void main() { expect(event, isA()); final toolStart = event as ToolCallStartEvent; expect(toolStart.toolCallId, 'tc_123'); - expect(toolStart.toolCallName, 'create_calendar_event'); + expect(toolStart.toolCallName, 'back.mutate_calendar_event'); expect(toolStart.parentMessageId, 'msg_001'); }); @@ -265,6 +265,37 @@ void main() { expect(toolResult.result['eventId'], 'evt_001'); }); + test('ToolCallResultEvent.ui parses from payload.ui', () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_123', + 'toolCallId': 'tc_123', + 'content': + '{"ui":{"type":"calendar_card.v1","version":"v1","data":{"id":"evt_1","title":"会议","startAt":"2026-03-01T10:00:00Z"},"actions":[]}}', + }; + + final event = AgUiEvent.fromJson(json) as ToolCallResultEvent; + expect(event.ui, isNotNull); + expect(event.ui!.cardType, 'calendar_card.v1'); + }); + + test( + 'ToolCallResultEvent.ui parses from payload.result when result is UiCard', + () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_123', + 'toolCallId': 'tc_123', + 'content': + '{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true},"actions":[]}}', + }; + + final event = AgUiEvent.fromJson(json) as ToolCallResultEvent; + expect(event.ui, isNotNull); + expect(event.ui!.cardType, 'calendar_operation.v1'); + }, + ); + test('parses ToolCallErrorEvent', () { final json = { 'type': 'TOOL_CALL_ERROR', diff --git a/apps/test/features/chat/ag_ui_service_test.dart b/apps/test/features/chat/ag_ui_service_test.dart index 6953abd..cb97eb9 100644 --- a/apps/test/features/chat/ag_ui_service_test.dart +++ b/apps/test/features/chat/ag_ui_service_test.dart @@ -26,8 +26,6 @@ class TestableAgUiService extends AgUiService { final forceTrigger = engine.tryForceTrigger(content); if (forceTrigger != null) { await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args); - } else if (engine.shouldTriggerToolCall(content)) { - await mockToolCallFlow(content, engine); } final replies = generateReplies(content, engine); @@ -38,13 +36,6 @@ class TestableAgUiService extends AgUiService { onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); } - Future mockToolCallFlow(String content, AiDecisionEngine engine) async { - final args = engine.getToolCallArgs(content); - if (args == null) return; - - await mockToolCallFlowWithArgs('create_calendar_event', args); - } - Future mockToolCallFlowWithArgs( String toolName, Map args, @@ -57,6 +48,10 @@ class TestableAgUiService extends AgUiService { onEvent(ToolCallEndEvent(toolCallId: toolCallId)); + if (toolName == 'front.navigate_to_route') { + return; + } + final validation = ToolRegistry.validateArgs(toolName, args); if (!validation.ok) { onEvent( @@ -71,7 +66,7 @@ class TestableAgUiService extends AgUiService { try { ToolRegistry.initialize(); - final result = await ToolRegistry.execute(toolName, args); + await ToolRegistry.execute(toolName, args); final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; onEvent( @@ -157,28 +152,30 @@ void main() { expect(types.last, AgUiEventType.runFinished); }); - test('creating schedule text triggers tool call events', () async { - await service.sendMessage('提醒我明天10点开会'); + test( + 'creating schedule text does not trigger frontend tool call events', + () async { + await service.sendMessage('提醒我明天10点开会'); - final toolCallStarts = capturedEvents - .whereType() - .toList(); - final toolCallEnds = capturedEvents - .whereType() - .toList(); - final toolCallResults = capturedEvents - .whereType() - .toList(); + final toolCallStarts = capturedEvents + .whereType() + .toList(); + final toolCallEnds = capturedEvents + .whereType() + .toList(); + final toolCallResults = capturedEvents + .whereType() + .toList(); - expect(toolCallStarts.isNotEmpty, true); - expect(toolCallEnds.isNotEmpty, true); - expect(toolCallResults.isNotEmpty, true); - expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); - }); + expect(toolCallStarts.isEmpty, true); + expect(toolCallEnds.isEmpty, true); + expect(toolCallResults.isEmpty, true); + }, + ); test('force trigger with #tool syntax', () async { await service.sendMessage( - '#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}', + '#tool:front.navigate_to_route {"target": "/calendar/dayweek"}', ); final toolCallStarts = capturedEvents @@ -186,7 +183,7 @@ void main() { .toList(); expect(toolCallStarts.isNotEmpty, true); - expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); + expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route'); }); test('text message events are emitted for unknown intent', () async { @@ -215,15 +212,18 @@ void main() { expect(toolCallStarts.isEmpty, true); }); - test('tool call with invalid args emits error', () async { - await service.sendMessage('#tool:create_calendar_event {}'); + test('frontend tool call keeps pending state before approval', () async { + await service.sendMessage('#tool:front.navigate_to_route {}'); final toolCallErrors = capturedEvents .whereType() .toList(); + final toolCallStarts = capturedEvents + .whereType() + .toList(); - expect(toolCallErrors.isNotEmpty, true); - expect(toolCallErrors.first.error, contains('Missing required fields')); + expect(toolCallStarts.isNotEmpty, true); + expect(toolCallErrors.isEmpty, true); }); }); @@ -319,7 +319,7 @@ void main() { await service.sendMessage('初始化会话'); await service.approveToolCall( toolCallId: 'call-1', - toolName: 'navigate_to_route', + toolName: 'front.navigate_to_route', args: { 'target': '/calendar/dayweek', 'replace': false, @@ -349,7 +349,7 @@ void main() { (e) => e.toolCallId == toolStart.toolCallId, ); final toolArgs = jsonDecode(toolArgsEvent.delta) as Map; - expect(toolStart.toolCallName, 'navigate_to_route'); + expect(toolStart.toolCallName, 'front.navigate_to_route'); expect( events .whereType() @@ -360,7 +360,7 @@ void main() { await realService.approveToolCall( toolCallId: toolStart.toolCallId, - toolName: 'navigate_to_route', + toolName: 'front.navigate_to_route', args: toolArgs, ); @@ -387,7 +387,7 @@ void main() { expect( () => realService.approveToolCall( toolCallId: toolStart.toolCallId, - toolName: 'navigate_to_route', + toolName: 'front.navigate_to_route', args: toolArgs, ), throwsA(isA()), diff --git a/apps/test/features/chat/ai_decision_engine_test.dart b/apps/test/features/chat/ai_decision_engine_test.dart index 79b25b9..0bce7e4 100644 --- a/apps/test/features/chat/ai_decision_engine_test.dart +++ b/apps/test/features/chat/ai_decision_engine_test.dart @@ -112,13 +112,18 @@ void main() { }); group('tryForceTrigger', () { - test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () { - final result = engine.tryForceTrigger('#tool:create_calendar_event {}'); + test( + 'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"', + () { + final result = engine.tryForceTrigger( + '#tool:front.navigate_to_route {}', + ); - expect(result, isNotNull); - expect(result!.toolName, 'create_calendar_event'); - expect(result.args, isEmpty); - }); + expect(result, isNotNull); + expect(result!.toolName, 'front.navigate_to_route'); + expect(result.args, isEmpty); + }, + ); test( 'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"', diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart index e2ad8c1..ed4c790 100644 --- a/apps/test/features/chat/chat_bloc_test.dart +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -194,7 +194,7 @@ void main() { service.onEvent( ToolCallStartEvent( toolCallId: 'tc_1', - toolCallName: 'create_calendar_event', + toolCallName: 'back.mutate_calendar_event', ), ); }, @@ -203,7 +203,7 @@ void main() { (s) { final item = s.items.first; return item is ToolCallItem && - item.toolName == 'create_calendar_event' && + item.toolName == 'back.mutate_calendar_event' && item.status == ToolCallStatus.pending; }, 'has pending tool call', @@ -220,7 +220,7 @@ void main() { ToolCallItem( id: 'tc_1', callId: 'tc_1', - toolName: 'navigate_to_route', + toolName: 'front.navigate_to_route', args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'}, status: ToolCallStatus.executing, timestamp: DateTime.now(), @@ -241,5 +241,40 @@ void main() { isA().having((s) => s.items.isEmpty, 'items empty', true), ], ); + + blocTest( + 'toolCallResult with ui in payload.result adds ToolResultItem', + build: () => chatBloc, + seed: () => ChatState( + items: [ + ToolCallItem( + id: 'tc_2', + callId: 'tc_2', + toolName: 'back.mutate_calendar_event', + args: {'operation': 'create'}, + status: ToolCallStatus.executing, + timestamp: DateTime.now(), + sender: MessageSender.ai, + ), + ], + ), + act: (bloc) { + service.onEvent( + ToolCallResultEvent( + messageId: 'msg_tool_2', + toolCallId: 'tc_2', + content: + '{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}', + ), + ); + }, + expect: () => [ + isA().having( + (s) => s.items.first is ToolResultItem, + 'first item is ToolResultItem', + true, + ), + ], + ); }); } diff --git a/apps/test/features/chat/tool_registry_test.dart b/apps/test/features/chat/tool_registry_test.dart index 0ad6fe8..0d26bb8 100644 --- a/apps/test/features/chat/tool_registry_test.dart +++ b/apps/test/features/chat/tool_registry_test.dart @@ -12,11 +12,11 @@ void main() { }); group('getTool', () { - test('returns tool definition for create_calendar_event', () { - final tool = ToolRegistry.getTool('create_calendar_event'); + test('returns tool definition for front.navigate_to_route', () { + final tool = ToolRegistry.getTool('front.navigate_to_route'); expect(tool, isNotNull); - expect(tool!.name, 'create_calendar_event'); + expect(tool!.name, 'front.navigate_to_route'); expect(tool.description, isNotEmpty); }); @@ -26,26 +26,16 @@ void main() { }); group('validateArgs', () { - test('returns error for empty args (missing title)', () { - final result = ToolRegistry.validateArgs('create_calendar_event', {}); + test('returns error for empty args (missing target)', () { + final result = ToolRegistry.validateArgs('front.navigate_to_route', {}); expect(result.ok, false); - expect(result.error, contains('title')); + expect(result.error, contains('target')); }); - test('returns error when missing startAt', () { - final result = ToolRegistry.validateArgs('create_calendar_event', { - 'title': 'Test Event', - }); - - expect(result.ok, false); - expect(result.error, contains('startAt')); - }); - - test('returns ok: true for valid args with title and startAt', () { - final result = ToolRegistry.validateArgs('create_calendar_event', { - 'title': 'x', - 'startAt': 'x', + test('returns ok: true for valid args', () { + final result = ToolRegistry.validateArgs('front.navigate_to_route', { + 'target': '/settings', }); expect(result.ok, true); @@ -61,17 +51,6 @@ void main() { }); group('execute', () { - test('returns eventId on success', () async { - final result = await ToolRegistry.execute('create_calendar_event', { - 'title': 'Test Meeting', - 'startAt': '2026-03-01T10:00:00Z', - }); - - expect(result['eventId'], isNotNull); - expect(result['ok'], true); - expect(result['title'], 'Test Meeting'); - }); - test('throws ToolNotFoundException for unknown tool', () async { expect( () => ToolRegistry.execute('unknown_tool', {}), @@ -79,22 +58,8 @@ void main() { ); }); - test('includes optional fields in result', () async { - final result = await ToolRegistry.execute('create_calendar_event', { - 'title': 'Test', - 'startAt': '2026-03-01T10:00:00Z', - 'description': 'Description', - 'location': 'Room A', - 'endAt': '2026-03-01T11:00:00Z', - }); - - expect(result['description'], 'Description'); - expect(result['location'], 'Room A'); - expect(result['endAt'], '2026-03-01T11:00:00Z'); - }); - - test('navigate_to_route rejects disallowed target', () async { - final result = await ToolRegistry.execute('navigate_to_route', { + test('front.navigate_to_route rejects disallowed target', () async { + final result = await ToolRegistry.execute('front.navigate_to_route', { 'target': '/admin', }); @@ -102,23 +67,26 @@ void main() { expect(result['error'], contains('not allowed')); }); - test('navigate_to_route executes allowed target when navigator is bound', () async { - String? navigatedTo; - bool replaced = false; - RouteNavigationTool.instance.bindNavigator((target, {replace = false}) { - navigatedTo = target; - replaced = replace; - }); + test( + 'front.navigate_to_route executes allowed target when navigator is bound', + () async { + String? navigatedTo; + bool replaced = false; + RouteNavigationTool.instance.bindNavigator((target, {replace = false}) { + navigatedTo = target; + replaced = replace; + }); - final result = await ToolRegistry.execute('navigate_to_route', { - 'target': '/settings', - 'replace': true, - }); + final result = await ToolRegistry.execute('front.navigate_to_route', { + 'target': '/settings', + 'replace': true, + }); - expect(result['ok'], true); - expect(navigatedTo, '/settings'); - expect(replaced, true); - }); + expect(result['ok'], true); + expect(navigatedTo, '/settings'); + expect(replaced, true); + }, + ); }); group('getAllTools', () { @@ -126,7 +94,8 @@ void main() { final tools = ToolRegistry.getAllTools(); expect(tools, isNotEmpty); - expect(tools.any((t) => t.name == 'create_calendar_event'), true); + expect(tools.any((t) => t.name == 'front.navigate_to_route'), true); + expect(tools.any((t) => t.name == 'create_calendar_event'), false); }); }); } diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart index bb01e0e..7bcf0fb 100644 --- a/apps/test/features/chat/ui_schema_renderer_test.dart +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -94,6 +94,61 @@ void main() { expect(find.text('AI生成'), findsOneWidget); }); + testWidgets('calendar_card.v1 renders agent generated tag', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + sourceType: 'agent_generated', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('AI生成'), findsOneWidget); + }); + + testWidgets('calendar_event_list.v1 renders list items', (tester) async { + final card = UiCard( + cardType: 'calendar_event_list.v1', + data: { + 'items': [ + {'id': 'evt_1', 'title': '晨会'}, + {'id': 'evt_2', 'title': '评审'}, + ], + 'pagination': {'page': 1, 'total': 2}, + }, + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('日程列表'), findsOneWidget); + expect(find.text('晨会'), findsOneWidget); + expect(find.text('评审'), findsOneWidget); + }); + + testWidgets('calendar_operation.v1 renders operation message', ( + tester, + ) async { + final card = UiCard( + cardType: 'calendar_operation.v1', + data: {'operation': 'delete', 'ok': true, 'message': '日程已删除'}, + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('日程delete结果'), findsOneWidget); + expect(find.text('日程已删除'), findsOneWidget); + }); + testWidgets('error_card.v1 renders error message', (tester) async { final card = UiCard( cardType: 'error_card.v1', 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 6b16e31..dc39b56 100644 --- a/apps/test/features/home/ui/screens/home_screen_test.dart +++ b/apps/test/features/home/ui/screens/home_screen_test.dart @@ -1,12 +1,36 @@ 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/home/data/voice_recorder.dart'; import 'package:social_app/features/home/ui/screens/home_screen.dart'; +class _FakeVoiceRecorder implements VoiceRecorder { + bool started = false; + String? stoppedPath; + + @override + Future start() async { + started = true; + } + + @override + Future stop() async { + started = false; + stoppedPath = '/tmp/test-audio.wav'; + return stoppedPath; + } + + @override + Future dispose() async {} +} + void main() { group('HomeScreen Widget Tests', () { testWidgets('displays input field', (WidgetTester tester) async { - await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpWidget( + const MaterialApp(home: HomeScreen(autoLoadHistory: false)), + ); await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); @@ -14,7 +38,9 @@ void main() { }); testWidgets('displays header icons', (WidgetTester tester) async { - await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpWidget( + const MaterialApp(home: HomeScreen(autoLoadHistory: false)), + ); await tester.pumpAndSettle(); expect(find.byIcon(LucideIcons.settings), findsOneWidget); @@ -25,10 +51,116 @@ void main() { testWidgets('displays send or mic icon based on input', ( WidgetTester tester, ) async { - await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpWidget( + const MaterialApp(home: HomeScreen(autoLoadHistory: false)), + ); await tester.pumpAndSettle(); expect(find.byIcon(LucideIcons.mic), findsOneWidget); }); + + testWidgets('tap mic starts recording and shows listening state', ( + WidgetTester tester, + ) async { + final fakeRecorder = _FakeVoiceRecorder(); + await tester.pumpWidget( + MaterialApp( + home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.pump(); + + expect(fakeRecorder.started, true); + expect(find.text('正在聆听...'), findsOneWidget); + expect(find.byIcon(LucideIcons.square), findsOneWidget); + expect(find.byIcon(LucideIcons.send), findsOneWidget); + }); + + testWidgets('tap send while recording transcribes and auto sends message', ( + WidgetTester tester, + ) async { + final fakeRecorder = _FakeVoiceRecorder(); + String? sentTranscript; + await tester.pumpWidget( + MaterialApp( + home: HomeScreen( + voiceRecorder: fakeRecorder, + autoLoadHistory: false, + onTranscribeAudio: (filePath) async { + expect(filePath, '/tmp/test-audio.wav'); + return '语音自动发送'; + }, + onAutoSendTranscript: (transcript) async { + sentTranscript = transcript; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.send)); + await tester.pumpAndSettle(); + + expect(sentTranscript, '语音自动发送'); + expect(find.byIcon(LucideIcons.plus), findsOneWidget); + }); + + testWidgets('tap stop transcribes audio and fills input', ( + WidgetTester tester, + ) async { + final fakeRecorder = _FakeVoiceRecorder(); + await tester.pumpWidget( + MaterialApp( + home: HomeScreen( + voiceRecorder: fakeRecorder, + autoLoadHistory: false, + onTranscribeAudio: (filePath) async { + expect(filePath, '/tmp/test-audio.wav'); + return '语音转文字结果'; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.square)); + await tester.pumpAndSettle(); + + expect(find.text('语音转文字结果'), findsOneWidget); + expect(find.byIcon(LucideIcons.plus), findsOneWidget); + }); + + testWidgets('tap stop shows readable unauthorized message', ( + WidgetTester tester, + ) async { + final fakeRecorder = _FakeVoiceRecorder(); + await tester.pumpWidget( + MaterialApp( + home: HomeScreen( + voiceRecorder: fakeRecorder, + autoLoadHistory: false, + onTranscribeAudio: (_) async { + throw const UnauthorizedException(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(LucideIcons.mic)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.square)); + await tester.pumpAndSettle(); + + expect(find.text('请重新登录'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + }); }); } diff --git a/backend/src/core/agent/infrastructure/crewai/runtime.py b/backend/src/core/agent/infrastructure/crewai/runtime.py index d623ef2..5c51244 100644 --- a/backend/src/core/agent/infrastructure/crewai/runtime.py +++ b/backend/src/core/agent/infrastructure/crewai/runtime.py @@ -141,11 +141,17 @@ class CrewAIRuntime: @staticmethod def _sanitize_backend_args(execution_data: dict[str, Any]) -> dict[str, object]: - dropped = {"event_id", "id", "message", "status", "result"} + dropped = {"event_id", "id", "message", "result"} cleaned: dict[str, object] = {} for key, value in execution_data.items(): if not isinstance(key, str) or key in dropped: continue + if ( + key == "status" + and isinstance(value, str) + and value.upper() in {"SUCCESS", "PARTIAL", "FAILED"} + ): + continue if isinstance(value, (str, int, float, bool)) or value is None: cleaned[key] = value return cleaned @@ -170,7 +176,7 @@ class CrewAIRuntime: ): return None backend_names = self._backend_tool_names(execution_tools) - if len(backend_names) != 1: + if not backend_names: return None if not hasattr(execution_result, "status") or not hasattr( execution_result, "execution_data" @@ -190,7 +196,39 @@ class CrewAIRuntime: args = self._sanitize_backend_args(raw_data) if not args: return None - tool_name = backend_names[0] + if len(backend_names) == 1: + tool_name = backend_names[0] + else: + mutate_name = "back.mutate_calendar_event" + list_name = "back.list_calendar_events" + write_keys = { + "operation", + "eventId", + "title", + "description", + "startAt", + "endAt", + "timezone", + "location", + "color", + "status", + } + list_keys = {"page", "pageSize"} + has_write_keys = any(key in args for key in write_keys) + has_event_id = "eventId" in args + if mutate_name in backend_names and has_write_keys: + tool_name = mutate_name + if "operation" not in args: + if has_event_id: + return None + args = {"operation": "create", **args} + elif list_name in backend_names and ( + any(key in args for key in list_keys) + or not any(key in args for key in write_keys) + ): + tool_name = list_name + else: + return None result = self._backend_tool_handler(tool_name, args) synthesized_call = { "name": tool_name, diff --git a/backend/src/core/agent/infrastructure/crewai/tools/__init__.py b/backend/src/core/agent/infrastructure/crewai/tools/__init__.py index 6050f10..aaa6b16 100644 --- a/backend/src/core/agent/infrastructure/crewai/tools/__init__.py +++ b/backend/src/core/agent/infrastructure/crewai/tools/__init__.py @@ -1,11 +1,13 @@ from __future__ import annotations from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import ( - CREATE_CALENDAR_EVENT_TOOL, + LIST_CALENDAR_EVENTS_TOOL, + MUTATE_CALENDAR_EVENT_TOOL, ) REGISTERED_TOOLS = { - CREATE_CALENDAR_EVENT_TOOL.name: CREATE_CALENDAR_EVENT_TOOL, + LIST_CALENDAR_EVENTS_TOOL.name: LIST_CALENDAR_EVENTS_TOOL, + MUTATE_CALENDAR_EVENT_TOOL.name: MUTATE_CALENDAR_EVENT_TOOL, } __all__ = ["REGISTERED_TOOLS"] diff --git a/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py b/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py index b5f65a7..e694358 100644 --- a/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py +++ b/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from datetime import datetime, timedelta, timezone from uuid import UUID @@ -8,10 +9,18 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.auth.models import CurrentUser from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository -from v1.schedule_items.schemas import ScheduleItemCreateRequest, ScheduleItemMetadata +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemMetadata, + ScheduleItemStatus, + ScheduleItemUpdateRequest, +) from v1.schedule_items.service import ScheduleItemService +_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$") + + def _parse_datetime(value: object) -> datetime | None: if not isinstance(value, str) or not value: return None @@ -24,10 +33,122 @@ def _parse_datetime(value: object) -> datetime | None: return None -async def _execute_create_calendar_event( +def _parse_positive_int( + value: object, + *, + default: int, + minimum: int, + maximum: int, +) -> int: + if isinstance(value, bool): + return default + candidate: int | float | str + if isinstance(value, (int, float, str)): + candidate = value + else: + return default + if isinstance(candidate, str): + candidate = candidate.strip() + try: + parsed = int(candidate) + except (TypeError, ValueError): + return default + if parsed < minimum: + return minimum + if parsed > maximum: + return maximum + return parsed + + +def _parse_event_id(value: object) -> UUID: + if not isinstance(value, str) or not value.strip(): + raise ValueError("eventId is required") + try: + return UUID(value) + except ValueError as exc: + raise ValueError("eventId must be a valid UUID") from exc + + +def _service(session: AsyncSession, owner_id: UUID) -> ScheduleItemService: + return ScheduleItemService( + repository=SQLAlchemyScheduleItemRepository(session), + session=session, + current_user=CurrentUser(id=owner_id), + ) + + +def _resolve_metadata(tool_args: dict[str, object]) -> ScheduleItemMetadata: + location = tool_args.get("location") + location_value = location.strip() if isinstance(location, str) else None + color = tool_args.get("color") + raw_color = color.strip() if isinstance(color, str) and color.strip() else "#4F46E5" + color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5" + return ScheduleItemMetadata(location=location_value, color=color_value) + + +def _event_payload(event: object) -> dict[str, object]: + event_id = str(getattr(event, "id")) + metadata = getattr(event, "metadata", None) + location_value = getattr(metadata, "location", None) + color_value = getattr(metadata, "color", None) or "#4F46E5" + return { + "id": event_id, + "title": getattr(event, "title"), + "description": getattr(event, "description"), + "startAt": getattr(event, "start_at").isoformat(), + "endAt": ( + getattr(event, "end_at").isoformat() + if getattr(event, "end_at") is not None + else None + ), + "timezone": getattr(event, "timezone"), + "location": location_value, + "color": color_value, + } + + +async def _execute_list_calendar_events( session: AsyncSession, owner_id: UUID, tool_args: dict[str, object], +) -> dict[str, object]: + page = _parse_positive_int( + tool_args.get("page"), + default=1, + minimum=1, + maximum=100000, + ) + page_size = _parse_positive_int( + tool_args.get("pageSize"), + default=20, + minimum=1, + maximum=100, + ) + service = _service(session, owner_id) + items, total = await service.list_paginated(page=page, page_size=page_size) + total_pages = max(1, (total + page_size - 1) // page_size) if total else 0 + return { + "type": "calendar_event_list.v1", + "version": "v1", + "data": { + "items": [_event_payload(item) for item in items], + "pagination": { + "page": page, + "pageSize": page_size, + "total": total, + "totalPages": total_pages, + }, + "ok": True, + "message": "已获取日程列表", + }, + "actions": [], + } + + +async def _execute_create( + *, + service: ScheduleItemService, + tool_args: dict[str, object], ) -> dict[str, object]: title = str(tool_args.get("title", "新的日程")).strip() or "新的日程" description = str(tool_args.get("description", "")).strip() or None @@ -35,15 +156,8 @@ async def _execute_create_calendar_event( if start_at is None: start_at = datetime.now(timezone.utc) + timedelta(hours=1) end_at = _parse_datetime(tool_args.get("endAt")) - timezone_value = str(tool_args.get("timezone", "Asia/Shanghai")) - location = tool_args.get("location") - location_value = str(location) if isinstance(location, str) else None - metadata = ScheduleItemMetadata(location=location_value, color="#4F46E5") - - service = ScheduleItemService( - repository=SQLAlchemyScheduleItemRepository(session), - session=session, - current_user=CurrentUser(id=owner_id), + timezone_value = ( + str(tool_args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai" ) created = await service.create_agent_generated( ScheduleItemCreateRequest( @@ -52,22 +166,16 @@ async def _execute_create_calendar_event( start_at=start_at, end_at=end_at, timezone=timezone_value, - metadata=metadata, + metadata=_resolve_metadata(tool_args), ) ) - event_id = str(created.id) + event_data = _event_payload(created) + event_id = str(event_data["id"]) return { "type": "calendar_card.v1", "version": "v1", "data": { - "id": event_id, - "title": created.title, - "description": created.description, - "startAt": created.start_at.isoformat(), - "endAt": created.end_at.isoformat() if created.end_at is not None else None, - "timezone": created.timezone, - "location": location_value, - "color": "#4F46E5", + **event_data, "sourceType": "agent_generated", "ok": True, "message": "日程已创建", @@ -82,8 +190,125 @@ async def _execute_create_calendar_event( } -CREATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec( - name="back.create_calendar_event", +async def _execute_update( + *, + service: ScheduleItemService, + tool_args: dict[str, object], +) -> dict[str, object]: + event_id = _parse_event_id(tool_args.get("eventId")) + update_data: dict[str, object] = {} + for source_key, target_key in ( + ("title", "title"), + ("description", "description"), + ("timezone", "timezone"), + ): + value = tool_args.get(source_key) + if isinstance(value, str): + update_data[target_key] = value.strip() + start_at = _parse_datetime(tool_args.get("startAt")) + if start_at is not None: + update_data["start_at"] = start_at + end_at = _parse_datetime(tool_args.get("endAt")) + if end_at is not None: + update_data["end_at"] = end_at + status_value = tool_args.get("status") + if isinstance(status_value, str) and status_value.strip(): + try: + update_data["status"] = ScheduleItemStatus(status_value.strip().lower()) + except ValueError as exc: + raise ValueError( + "status must be one of: active, completed, canceled, archived" + ) from exc + has_location = isinstance(tool_args.get("location"), str) + has_color = isinstance(tool_args.get("color"), str) + if has_location or has_color: + existing = await service.get_by_id(event_id) + metadata_dump = ( + existing.metadata.model_dump() if existing.metadata is not None else {} + ) + if has_location: + metadata_dump["location"] = str(tool_args.get("location")).strip() or None + if has_color: + color = str(tool_args.get("color")).strip() + if not color: + metadata_dump["color"] = None + elif _HEX_COLOR_PATTERN.match(color): + metadata_dump["color"] = color + else: + raise ValueError("color must be a hex string like #RRGGBB") + update_data["metadata"] = ScheduleItemMetadata.model_validate(metadata_dump) + + updated = await service.update( + event_id, + ScheduleItemUpdateRequest.model_validate(update_data), + ) + event_data = _event_payload(updated) + return { + "type": "calendar_card.v1", + "version": "v1", + "data": { + **event_data, + "sourceType": "agent_generated", + "ok": True, + "message": "日程已更新", + }, + "actions": [ + { + "type": "link", + "label": "查看详情", + "target": f"/calendar/events/{event_data['id']}", + } + ], + } + + +async def _execute_delete( + *, + service: ScheduleItemService, + tool_args: dict[str, object], +) -> dict[str, object]: + event_id = _parse_event_id(tool_args.get("eventId")) + await service.delete(event_id) + return { + "type": "calendar_operation.v1", + "version": "v1", + "data": { + "operation": "delete", + "id": str(event_id), + "ok": True, + "message": "日程已删除", + }, + "actions": [], + } + + +async def _execute_mutate_calendar_event( + session: AsyncSession, + owner_id: UUID, + tool_args: dict[str, object], +) -> dict[str, object]: + operation_raw = tool_args.get("operation") + if not isinstance(operation_raw, str) or not operation_raw.strip(): + raise ValueError("operation is required") + operation = operation_raw.strip().lower() + service = _service(session, owner_id) + if operation == "create": + return await _execute_create(service=service, tool_args=tool_args) + if operation == "update": + return await _execute_update(service=service, tool_args=tool_args) + if operation == "delete": + return await _execute_delete(service=service, tool_args=tool_args) + raise ValueError("operation must be one of: create, update, delete") + + +LIST_CALENDAR_EVENTS_TOOL = CrewAIToolSpec( + name="back.list_calendar_events", target="backend", - executor=_execute_create_calendar_event, + executor=_execute_list_calendar_events, +) + +MUTATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec( + name="back.mutate_calendar_event", + target="backend", + executor=_execute_mutate_calendar_event, ) diff --git a/backend/src/core/agent/infrastructure/crewai/tools/stage_tool_allowlist.py b/backend/src/core/agent/infrastructure/crewai/tools/stage_tool_allowlist.py index e2fadf5..a6ef407 100644 --- a/backend/src/core/agent/infrastructure/crewai/tools/stage_tool_allowlist.py +++ b/backend/src/core/agent/infrastructure/crewai/tools/stage_tool_allowlist.py @@ -4,7 +4,10 @@ from core.agent.infrastructure.crewai.tools import REGISTERED_TOOLS STAGE_TOOL_ALLOWLIST: dict[str, list[str]] = { "intent": [], - "execution": ["back.create_calendar_event"], + "execution": [ + "back.list_calendar_events", + "back.mutate_calendar_event", + ], "organization": [], } diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index 670154e..4b28bf2 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Protocol from uuid import UUID -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository @@ -33,6 +33,13 @@ class ScheduleItemRepository(Protocol): async def list_by_date_range( self, owner_id: UUID, start_at: datetime, end_at: datetime ) -> list[ScheduleItem]: ... + async def list_paginated( + self, + owner_id: UUID, + *, + page: int, + page_size: int, + ) -> tuple[list[ScheduleItem], int]: ... async def create_subscription(self, data: dict) -> ScheduleSubscription: ... @@ -131,11 +138,46 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): logger.exception("Schedule item list failed", owner_id=str(owner_id)) raise + async def list_paginated( + self, + owner_id: UUID, + *, + page: int, + page_size: int, + ) -> tuple[list[ScheduleItem], int]: + offset = (page - 1) * page_size + try: + count_stmt = ( + select(func.count()) + .select_from(ScheduleItem) + .where(ScheduleItem.owner_id == owner_id) + .where(ScheduleItem.deleted_at.is_(None)) + ) + count_result = await self._session.execute(count_stmt) + total = int(count_result.scalar_one() or 0) + + items_stmt = ( + select(ScheduleItem) + .where(ScheduleItem.owner_id == owner_id) + .where(ScheduleItem.deleted_at.is_(None)) + .order_by(ScheduleItem.start_at.asc(), ScheduleItem.id.asc()) + .offset(offset) + .limit(page_size) + ) + items_result = await self._session.execute(items_stmt) + items = list(items_result.scalars().all()) + return items, total + except SQLAlchemyError: + logger.exception( + "Schedule item paginated list failed", + owner_id=str(owner_id), + page=page, + page_size=page_size, + ) + raise + async def create_subscription(self, data: dict) -> ScheduleSubscription: sub = ScheduleSubscription(**data) self._session.add(sub) await self._session.flush() return sub - - async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: - return await super().get_by_id(entity_id) diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 08b1363..b39136e 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -202,6 +202,34 @@ class ScheduleItemService(BaseService): return [self._to_response(item) for item in items] + async def list_paginated( + self, + *, + page: int, + page_size: int, + ) -> tuple[list[ScheduleItemResponse], int]: + user_id = self.require_user_id() + if page < 1: + raise HTTPException(status_code=400, detail="page must be >= 1") + if page_size < 1 or page_size > 100: + raise HTTPException(status_code=400, detail="page_size must be 1..100") + try: + items, total = await self._repository.list_paginated( + user_id, + page=page, + page_size=page_size, + ) + except SQLAlchemyError: + logger.exception( + "Failed to list schedule items with pagination", + page=page, + page_size=page_size, + ) + raise HTTPException( + status_code=503, detail="Schedule item store unavailable" + ) + return [self._to_response(item) for item in items], total + async def share( self, item_id: UUID, request: ScheduleItemShareRequest ) -> ScheduleItemShareResponse: diff --git a/backend/tests/e2e/test_agent_live_flow.py b/backend/tests/e2e/test_agent_live_flow.py index 55436ce..e490342 100644 --- a/backend/tests/e2e/test_agent_live_flow.py +++ b/backend/tests/e2e/test_agent_live_flow.py @@ -355,7 +355,7 @@ async def test_agent_live_image_calendar_tool_persistence() -> None: else: payload = json.loads(str(downloaded)) - assert payload["toolName"] == "back.create_calendar_event" + assert payload["toolName"] == "back.mutate_calendar_event" finally: if uploaded_paths: try: diff --git a/backend/tests/unit/core/agent/test_create_calendar_event_tool.py b/backend/tests/unit/core/agent/test_create_calendar_event_tool.py deleted file mode 100644 index a6519a4..0000000 --- a/backend/tests/unit/core/agent/test_create_calendar_event_tool.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from types import SimpleNamespace -from typing import cast -from uuid import uuid4 - -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool import ( - _execute_create_calendar_event, -) - - -@pytest.mark.asyncio -async def test_create_calendar_event_tool_returns_ui_schema_v1_top_level( - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_id = uuid4() - created = SimpleNamespace( - id=event_id, - title="晨会", - description="同步计划", - start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc), - end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc), - timezone="Asia/Shanghai", - ) - - class _FakeService: - def __init__(self, **kwargs) -> None: - del kwargs - - async def create_agent_generated(self, payload): - del payload - return created - - class _FakeRepository: - def __init__(self, session) -> None: - del session - - monkeypatch.setattr( - "core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.ScheduleItemService", - _FakeService, - ) - monkeypatch.setattr( - "core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", - _FakeRepository, - ) - - result = cast( - dict[str, object], - await _execute_create_calendar_event( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - tool_args={"title": "晨会"}, - ), - ) - - assert result["type"] == "calendar_card.v1" - assert result["version"] == "v1" - data = cast(dict[str, object], result["data"]) - actions = cast(list[dict[str, object]], result["actions"]) - assert data["id"] == str(event_id) - assert actions diff --git a/backend/tests/unit/core/agent/test_crewai_runtime.py b/backend/tests/unit/core/agent/test_crewai_runtime.py index 44559bc..941c8ed 100644 --- a/backend/tests/unit/core/agent/test_crewai_runtime.py +++ b/backend/tests/unit/core/agent/test_crewai_runtime.py @@ -119,7 +119,8 @@ def test_runtime_needs_execution_and_collects_front_tool_call() -> None: assert isinstance(tools, list) assert any(t.get("name") == "front.navigate_to_route" for t in tools) execution_tools = cast(list[dict[str, object]], calls[1]["tools"]) - assert any(t.get("name") == "back.create_calendar_event" for t in execution_tools) + assert any(t.get("name") == "back.list_calendar_events" for t in execution_tools) + assert any(t.get("name") == "back.mutate_calendar_event" for t in execution_tools) assert result["assistant_text"] == "do it" assert result["pending_front_tool"] == { "name": "front.navigate_to_route", @@ -191,7 +192,7 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None: calls.append({"stage": stage, "tools": tools}) if stage == "intent": return ( - '{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.create_calendar_event","safety_flags":[]}', + '{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.mutate_calendar_event","safety_flags":[]}', UsageCost(1, 1, 2, 0.01), [], None, @@ -218,7 +219,8 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None: ) intent_tools = cast(list[dict[str, object]], calls[0]["tools"]) - assert any(t.get("name") == "back.create_calendar_event" for t in intent_tools) + assert any(t.get("name") == "back.list_calendar_events" for t in intent_tools) + assert any(t.get("name") == "back.mutate_calendar_event" for t in intent_tools) def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() -> None: @@ -267,18 +269,78 @@ def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() -> assert backend_calls == [ ( - "back.create_calendar_event", - {"title": "项目评审", "timezone": "Asia/Shanghai"}, + "back.mutate_calendar_event", + { + "operation": "create", + "title": "项目评审", + "timezone": "Asia/Shanghai", + }, ) ] tool_calls = cast(list[dict[str, object]], result["tool_calls"]) assert any( call.get("target") == "backend" - and call.get("name") == "back.create_calendar_event" + and call.get("name") == "back.mutate_calendar_event" for call in tool_calls ) +def test_runtime_does_not_synthesize_mutate_create_when_event_id_without_operation() -> ( + None +): + runtime = _build_runtime() + backend_calls: list[tuple[str, dict[str, object]]] = [] + + def _backend_handler( + tool_name: str, tool_args: dict[str, object] + ) -> dict[str, object]: + backend_calls.append((tool_name, tool_args)) + return {"type": "ok", "version": "v1", "data": {}, "actions": []} + + runtime.set_backend_tool_handler(_backend_handler) + + def _fake_run_stage(self, **kwargs): + stage = kwargs["stage"] + if stage == "intent": + return ( + '{"route":"NEEDS_EXECUTION","intent_summary":"update event","execution_brief":"update via backend tool","safety_flags":[]}', + UsageCost(1, 1, 2, 0.01), + [], + None, + ) + if stage == "execution": + return ( + '{"status":"SUCCESS","execution_summary":"updated","execution_data":{"eventId":"1c7e85f6-a2b4-4da3-a143-7f9af8ea1a3d","title":"修正标题"},"report_brief":"done"}', + UsageCost(2, 2, 4, 0.02), + [], + None, + ) + return ( + '{"assistant_text":"ok","response_metadata":{}}', + UsageCost(1, 1, 2, 0.01), + [], + None, + ) + + runtime._run_stage_with_crewai = MethodType(_fake_run_stage, runtime) # type: ignore[method-assign] + runtime.execute(user_input="更新日程", tools=[]) + + assert backend_calls == [] + + +def test_runtime_sanitize_backend_args_keeps_business_status() -> None: + payload = { + "status": "completed", + "title": "日程", + "result": "ignore", + "id": "ignore", + } + assert CrewAIRuntime._sanitize_backend_args(payload) == { + "status": "completed", + "title": "日程", + } + + def test_runtime_extracts_pending_front_tool_from_approval_required_shape() -> None: runtime = _build_runtime() @@ -423,7 +485,8 @@ def test_run_stage_with_crewai_uses_output_pydantic_for_stage( def test_runtime_backend_registry_check() -> None: runtime = _build_runtime() - assert runtime.is_registered_backend_tool("back.create_calendar_event") is True + assert runtime.is_registered_backend_tool("back.list_calendar_events") is True + assert runtime.is_registered_backend_tool("back.mutate_calendar_event") is True assert runtime.is_registered_backend_tool("back.unknown") is False diff --git a/backend/tests/unit/core/agent/test_list_calendar_events_tool.py b/backend/tests/unit/core/agent/test_list_calendar_events_tool.py new file mode 100644 index 0000000..5e19b17 --- /dev/null +++ b/backend/tests/unit/core/agent/test_list_calendar_events_tool.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import cast +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import ( + _execute_list_calendar_events, +) + + +@pytest.mark.asyncio +async def test_list_calendar_events_tool_returns_paginated_payload_v1( + monkeypatch: pytest.MonkeyPatch, +) -> None: + first_id = uuid4() + second_id = uuid4() + items = [ + SimpleNamespace( + id=first_id, + title="晨会", + description="同步", + start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc), + timezone="Asia/Shanghai", + metadata=SimpleNamespace(location="会议室A", color="#4F46E5"), + ), + SimpleNamespace( + id=second_id, + title="评审", + description=None, + start_at=datetime(2026, 3, 8, 3, 0, tzinfo=timezone.utc), + end_at=None, + timezone="Asia/Shanghai", + metadata=None, + ), + ] + + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def list_paginated(self, *, page: int, page_size: int): + assert page == 2 + assert page_size == 10 + return items, 37 + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + result = cast( + dict[str, object], + await _execute_list_calendar_events( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={"page": 2, "pageSize": 10}, + ), + ) + + assert result["type"] == "calendar_event_list.v1" + assert result["version"] == "v1" + data = cast(dict[str, object], result["data"]) + pagination = cast(dict[str, object], data["pagination"]) + events = cast(list[dict[str, object]], data["items"]) + assert pagination == { + "page": 2, + "pageSize": 10, + "total": 37, + "totalPages": 4, + } + assert events[0]["id"] == str(first_id) + assert events[0]["title"] == "晨会" + assert events[1]["id"] == str(second_id) + + +@pytest.mark.asyncio +async def test_list_calendar_events_tool_uses_default_pagination_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def list_paginated(self, *, page: int, page_size: int): + assert page == 1 + assert page_size == 20 + return [], 0 + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + result = cast( + dict[str, object], + await _execute_list_calendar_events( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={}, + ), + ) + + data = cast(dict[str, object], result["data"]) + pagination = cast(dict[str, object], data["pagination"]) + assert pagination["page"] == 1 + assert pagination["pageSize"] == 20 diff --git a/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py b/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py new file mode 100644 index 0000000..6411f9e --- /dev/null +++ b/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import cast +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import ( + _execute_mutate_calendar_event, +) + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_create_returns_calendar_card_v1( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_id = uuid4() + + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def create_agent_generated(self, payload): + assert payload.title == "晨会" + return SimpleNamespace( + id=created_id, + title="晨会", + description="同步计划", + start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc), + timezone="Asia/Shanghai", + metadata=SimpleNamespace(location="会议室A", color="#4F46E5"), + ) + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + result = cast( + dict[str, object], + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={ + "operation": "create", + "title": "晨会", + "description": "同步计划", + "startAt": "2026-03-08T09:00:00+08:00", + "endAt": "2026-03-08T10:00:00+08:00", + "timezone": "Asia/Shanghai", + "location": "会议室A", + }, + ), + ) + + assert result["type"] == "calendar_card.v1" + data = cast(dict[str, object], result["data"]) + assert data["id"] == str(created_id) + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_update_requires_event_id() -> None: + with pytest.raises(ValueError, match="eventId is required"): + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={"operation": "update", "title": "新标题"}, + ) + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_delete_returns_ack( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted_id = uuid4() + + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def delete(self, item_id): + assert item_id == deleted_id + return None + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + result = cast( + dict[str, object], + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={"operation": "delete", "eventId": str(deleted_id)}, + ), + ) + + assert result["type"] == "calendar_operation.v1" + data = cast(dict[str, object], result["data"]) + assert data["operation"] == "delete" + assert data["id"] == str(deleted_id) + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_rejects_invalid_operation() -> None: + with pytest.raises(ValueError, match="operation"): + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={"operation": "upsert"}, + ) + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_update_rejects_invalid_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_id = uuid4() + + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def get_by_id(self, item_id): + assert item_id == event_id + return SimpleNamespace(metadata=None) + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + with pytest.raises(ValueError, match="color"): + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={ + "operation": "update", + "eventId": str(event_id), + "color": "blue", + }, + ) 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 dc2e7ed..769df14 100644 --- a/backend/tests/unit/core/agent/test_run_resume_service.py +++ b/backend/tests/unit/core/agent/test_run_resume_service.py @@ -646,7 +646,7 @@ async def test_run_service_passes_user_context_system_prompt_to_runtime( tool_args, ): del session, owner_id - assert tool_name == "back.create_calendar_event" + assert tool_name == "back.mutate_calendar_event" assert "title" in tool_args return { "result": {"eventId": "evt-1", "ok": True}, @@ -788,7 +788,7 @@ async def test_run_service_emits_frontend_tool_pending_events( class _FakeRuntime: def is_registered_backend_tool(self, tool_name: str) -> bool: - return tool_name == "back.create_calendar_event" + return tool_name == "back.mutate_calendar_event" async def execute_backend_tool( self, @@ -799,7 +799,7 @@ async def test_run_service_emits_frontend_tool_pending_events( tool_args, ): del session, owner_id - assert tool_name == "back.create_calendar_event" + assert tool_name == "back.mutate_calendar_event" assert "title" in tool_args return { "result": {"eventId": "evt-1", "ok": True}, @@ -957,7 +957,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result( class _FakeRuntime: def is_registered_backend_tool(self, tool_name: str) -> bool: - return tool_name == "back.create_calendar_event" + return tool_name == "back.mutate_calendar_event" async def execute_backend_tool( self, @@ -968,7 +968,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result( tool_args, ): del session, owner_id - assert tool_name == "back.create_calendar_event" + assert tool_name == "back.mutate_calendar_event" assert "title" in tool_args return { "result": {"eventId": "evt-1", "ok": True}, @@ -1043,7 +1043,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result( text="请安排一个明早会议", tools=[ { - "name": "back.create_calendar_event", + "name": "back.mutate_calendar_event", "description": "create calendar", "parameters": {"type": "object"}, } diff --git a/backend/tests/unit/core/agent/test_stage_tool_allowlist.py b/backend/tests/unit/core/agent/test_stage_tool_allowlist.py index ddc89d1..266d961 100644 --- a/backend/tests/unit/core/agent/test_stage_tool_allowlist.py +++ b/backend/tests/unit/core/agent/test_stage_tool_allowlist.py @@ -10,7 +10,10 @@ def test_load_crewai_stage_tools_returns_expected_defaults() -> None: assert result == { "intent": [], - "execution": ["back.create_calendar_event"], + "execution": [ + "back.list_calendar_events", + "back.mutate_calendar_event", + ], "organization": [], }