From f126d7a5470f651ecfdb11a974f36e3205097099 Mon Sep 17 00:00:00 2001 From: zl-q Date: Sun, 29 Mar 2026 21:46:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=95=B0=E6=8D=AE=E5=B1=82=E8=87=B3core=E5=B9=B6?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E9=A6=96=E9=A1=B5UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/AGENTS.md | 11 +- apps/lib/app/di/injection.dart | 11 +- apps/lib/app/router/app_router.dart | 11 +- .../services/app_prewarm_orchestrator.dart | 2 +- .../models => core/chat}/ag_ui_event.dart | 0 .../services => core/chat}/ag_ui_service.dart | 172 ++++++------------ apps/lib/core/chat/chat_api.dart | 28 +++ .../chat}/chat_history_repository.dart | 35 +--- .../chat/data/apis/chat_api_impl.dart | 142 +++++++++++++++ .../presentation/bloc/ag_ui_event_label.dart | 2 +- .../chat/presentation/bloc/chat_bloc.dart | 31 ++-- .../presentation/screens/home_screen.dart | 31 +--- .../widgets/home_background_field.dart | 71 +------- .../app/router/app_router_redirect_test.dart | 49 +++-- .../app_prewarm_orchestrator_test.dart | 56 +++++- .../chat_history_repository_test.dart | 83 ++++++--- backend/src/v1/agent/router.py | 54 ++++-- .../tests/integration/v1/agent/test_routes.py | 107 +++++++++++ 18 files changed, 568 insertions(+), 328 deletions(-) rename apps/lib/{features/chat/data/models => core/chat}/ag_ui_event.dart (100%) rename apps/lib/{features/chat/data/services => core/chat}/ag_ui_service.dart (72%) create mode 100644 apps/lib/core/chat/chat_api.dart rename apps/lib/{features/chat/data/repositories => core/chat}/chat_history_repository.dart (71%) create mode 100644 apps/lib/features/chat/data/apis/chat_api_impl.dart diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 49beafd..f77a4a3 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -62,7 +62,7 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - Agent chat must follow AG-UI over SSE. - Lifecycle events are mandatory: `RUN_STARTED` and exactly one of `RUN_FINISHED` or `RUN_ERROR`. -- Text streaming flow must be `TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT -> TEXT_MESSAGE_END`. +- Current default text delivery is finalized `TEXT_MESSAGE_END` payloads; do not require token-level `TEXT_MESSAGE_CONTENT` unless backend protocol explicitly enables it. ## HTTP Error Parse Contract (Must) @@ -92,9 +92,18 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - Shared cache infrastructure (`apps/lib/data/cache/`) must remain domain-agnostic: do not import `features/**` or business model DTOs there. - Domain object serialization/deserialization belongs to repository/feature layer via local mappers/codecs; do not centralize feature-specific codecs in shared cache layer. - Shared cache layer may only encode/decode primitives, collections, and cache metadata wrappers. +- Cache strategy default is `SWR + TTL + invalidation/reload`. +- Local partial cache patching is allowed only for simple single-entity updates with clear rollback paths; complex cross-list/cross-feature states must invalidate and refetch. +- Feature TTL policy must be defined in each feature repository; do not add centralized feature TTL registries in shared cache infra. +- Runtime cache is hybrid (`memory + local persistent`) managed by DI singletons; do not create per-screen/per-widget cache store instances. - Cross-feature data access must go through app-level facade/usecase boundaries; do not import another feature's data implementation directly from UI/Bloc. - Repository instances should be resolved from DI singletons to reuse cache and avoid per-feature re-creation. +### Reminder / Notification Rewrite Boundary + +- Reminder/notification data-interaction logic is under rewrite. Do not reintroduce local-notification scheduling/callback execution paths in `apps/lib/data/services/`. +- During rewrite, keep protocol/orchestration in `core/notification/**` and reusable rendering in `shared/widgets/notification/**`. + ## Testing Policy - Prioritize tests for model parsing, service logic, and high-regression interaction flows. diff --git a/apps/lib/app/di/injection.dart b/apps/lib/app/di/injection.dart index 9f0e4ca..b4b4fe5 100644 --- a/apps/lib/app/di/injection.dart +++ b/apps/lib/app/di/injection.dart @@ -18,7 +18,9 @@ import '../../features/auth/data/repositories/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/chat/presentation/bloc/chat_bloc.dart'; -import '../../features/chat/data/repositories/chat_history_repository.dart'; +import '../../core/chat/chat_api.dart'; +import '../../core/chat/chat_history_repository.dart'; +import '../../features/chat/data/apis/chat_api_impl.dart'; import '../../features/calendar/data/apis/calendar_api.dart'; import '../../features/calendar/data/services/calendar_service.dart'; import '../../shared/state/calendar_state_manager.dart'; @@ -127,8 +129,11 @@ Future configureDependencies() async { InboxRepositoryImpl(apiClient: apiClient, store: hybridCacheStore), ); + final chatApi = ChatApiImpl(apiClient); + sl.registerSingleton(chatApi); + final chatHistoryRepository = ChatHistoryRepository( - apiClient: apiClient, + chatApi: chatApi, store: hybridCacheStore, ); sl.registerSingleton(chatHistoryRepository); @@ -167,7 +172,7 @@ Future configureDependencies() async { sl.registerSingleton(authBloc); sl.registerSingleton(AuthSessionController(authBloc)); sl.registerSingleton( - ChatBloc(apiClient: apiClient, historyRepository: chatHistoryRepository), + ChatBloc(chatApi: chatApi, historyRepository: chatHistoryRepository), ); apiClient.setRefreshCallback((token) async { diff --git a/apps/lib/app/router/app_router.dart b/apps/lib/app/router/app_router.dart index a375330..8d8dea4 100644 --- a/apps/lib/app/router/app_router.dart +++ b/apps/lib/app/router/app_router.dart @@ -73,21 +73,12 @@ String? resolveAuthRedirect({ final isProtected = isHomeRoute || _protectedRoutes.any((route) => matchedLocation.startsWith(route)); - final prewarmStatus = prewarm?.status ?? AppPrewarmStatus.completed; - final shouldBlockForPrewarm = - isAuthenticated && prewarmStatus == AppPrewarmStatus.running; - - if (shouldBlockForPrewarm && !isBootRoute) { - return AppRoutes.authBoot; - } + final _ = prewarm; if (isAuthChecking && !isBootRoute) { return AppRoutes.authBoot; } if (!isAuthChecking && isBootRoute) { - if (shouldBlockForPrewarm) { - return null; - } return isAuthenticated ? AppRoutes.homeMain : AppRoutes.authLogin; } if (!isAuthenticated && isProtected) { diff --git a/apps/lib/app/services/app_prewarm_orchestrator.dart b/apps/lib/app/services/app_prewarm_orchestrator.dart index df88fca..221f13a 100644 --- a/apps/lib/app/services/app_prewarm_orchestrator.dart +++ b/apps/lib/app/services/app_prewarm_orchestrator.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import '../../features/calendar/data/repositories/calendar_repository.dart'; import '../../features/messages/data/repositories/inbox_repository.dart'; -import '../../features/chat/data/repositories/chat_history_repository.dart'; +import '../../core/chat/chat_history_repository.dart'; enum AppPrewarmStatus { idle, running, completed, timedOut, failed } diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/core/chat/ag_ui_event.dart similarity index 100% rename from apps/lib/features/chat/data/models/ag_ui_event.dart rename to apps/lib/core/chat/ag_ui_event.dart diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/core/chat/ag_ui_service.dart similarity index 72% rename from apps/lib/features/chat/data/services/ag_ui_service.dart rename to apps/lib/core/chat/ag_ui_service.dart index c4a27a2..2e3ee78 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/core/chat/ag_ui_service.dart @@ -3,17 +3,28 @@ import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; -import 'package:dio/dio.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:social_app/data/network/i_api_client.dart'; - -import '../models/ag_ui_event.dart'; -import '../repositories/chat_history_repository.dart'; +import 'chat_api.dart'; +import 'ag_ui_event.dart'; +import 'chat_history_repository.dart'; typedef EventCallback = void Function(AgUiEvent event); const _runIdPrefix = 'run_'; +class AttachmentUploadInput { + const AttachmentUploadInput({ + required this.name, + required this.mimeType, + required this.bytes, + required this.localPath, + }); + + final String name; + final String mimeType; + final Uint8List bytes; + final String localPath; +} + class UploadedAttachment { const UploadedAttachment({ required this.localPath, @@ -43,7 +54,7 @@ class _RunInputPayload { } class AgUiService { - final IApiClient _apiClient; + final ChatApi _chatApi; final ChatHistoryRepository? _historyRepository; EventCallback onEvent; final Map _lastEventIdByThread = {}; @@ -58,30 +69,23 @@ class AgUiService { AgUiService({ EventCallback? onEvent, - required IApiClient apiClient, + required ChatApi chatApi, ChatHistoryRepository? historyRepository, }) : onEvent = onEvent ?? ((_) {}), - _apiClient = apiClient, + _chatApi = chatApi, _historyRepository = historyRepository; Future sendMessage( String content, { - List? images, + List? attachments, }) async { await _cancelActiveSseSubscription(); final streamToken = ++_activeStreamToken; final runInputPayload = await _buildRunInput( content: content, - images: images, + attachments: attachments, ); - final response = await _apiClient.post>( - '/api/v1/agent/runs', - data: runInputPayload.input, - ); - final payload = response.data; - if (payload is! Map) { - throw StateError('Invalid /agent/runs response'); - } + final payload = await _chatApi.createRun(runInputPayload.input); final threadId = payload['threadId'] as String?; final runId = payload['runId'] as String?; if (threadId == null || threadId.isEmpty) { @@ -126,49 +130,18 @@ class AgUiService { } Future _loadHistoryFromApi({DateTime? beforeDate}) async { - final path = _buildHistoryPath(beforeDate: beforeDate); - final response = await _apiClient.get>(path); - final payload = response.data; - if (payload is! Map) { - throw StateError('Invalid /agent/history response'); - } + final payload = await _chatApi.fetchHistory( + threadId: _threadId, + beforeDate: beforeDate, + ); return HistorySnapshot.fromJson(payload); } - Future fetchAttachmentPreview(String previewPath) async { - final response = await _apiClient.get>( - previewPath, - options: Options(responseType: ResponseType.bytes), - ); - final payload = response.data; - if (payload is List) { - return Uint8List.fromList(payload); - } - throw StateError('Invalid attachment payload'); - } + Future fetchAttachmentPreview(String previewPath) => + _chatApi.fetchAttachmentPreview(previewPath); - Future transcribeAudio(String filePath) async { - final formData = FormData.fromMap({ - 'audio': await MultipartFile.fromFile( - filePath, - filename: 'recording.wav', - contentType: DioMediaType('audio', '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 transcribeAudio(String filePath) => + _chatApi.transcribeAudio(filePath); bool hasEarlierHistory(DateTime fromDate) { // Whether earlier history exists is driven by backend snapshot.hasMore. @@ -181,10 +154,7 @@ class AgUiService { final activeThreadId = _activeThreadIdForRun; final activeRunId = _activeRunId; if (activeThreadId != null && activeRunId != null) { - final encodedRunId = Uri.encodeQueryComponent(activeRunId); - await _apiClient.post>( - '/api/v1/agent/runs/$activeThreadId/cancel?runId=$encodedRunId', - ); + await _chatApi.cancelRun(threadId: activeThreadId, runId: activeRunId); _activeThreadIdForRun = null; _activeRunId = null; _activeStreamToken += 1; @@ -214,14 +184,9 @@ class AgUiService { required String expectedRunId, required int streamToken, }) async { - final lastEventId = _lastEventIdByThread[threadId]; - final headers = {'Accept': 'text/event-stream'}; - if (lastEventId != null && lastEventId.isNotEmpty) { - headers['Last-Event-ID'] = lastEventId; - } - final sseLines = await _apiClient.getSseLines( - '/api/v1/agent/runs/$threadId/events', - headers: headers, + final sseLines = await _chatApi.streamRunEvents( + threadId, + lastEventId: _lastEventIdByThread[threadId], ); String? eventType; @@ -369,7 +334,7 @@ class AgUiService { Future<_RunInputPayload> _buildRunInput({ required String content, - List? images, + List? attachments, }) async { final threadId = _threadId ?? _newUuid(); final runId = _nextId(_runIdPrefix); @@ -381,10 +346,10 @@ class AgUiService { } var uploadedAttachments = const []; - if (images != null && images.isNotEmpty) { + if (attachments != null && attachments.isNotEmpty) { uploadedAttachments = await _uploadAttachments( threadId: threadId, - images: images, + attachments: attachments, ); for (final attachment in uploadedAttachments) { contentBlocks.add({ @@ -423,36 +388,24 @@ class AgUiService { Future> _uploadAttachments({ required String threadId, - required List images, + required List attachments, }) async { - final attachments = []; - for (final image in images) { - final mimeType = image.mimeType ?? 'image/jpeg'; - final fileBytes = await image.readAsBytes(); - final formData = FormData.fromMap({ - 'threadId': threadId, - 'file': MultipartFile.fromBytes( - fileBytes, - filename: image.name, - contentType: DioMediaType.parse(mimeType), - ), - }); - final response = await _apiClient.post>( - '/api/v1/agent/attachments', - data: formData, + final uploaded = []; + for (final attachment in attachments) { + final payload = await _chatApi.uploadAttachment( + threadId: threadId, + filename: attachment.name, + mimeType: attachment.mimeType, + bytes: attachment.bytes, ); - final payload = response.data; - if (payload is! Map) { - throw StateError('Invalid /agent/attachments response'); - } - final attachment = payload['attachment']; - if (attachment is! Map) { + final payloadAttachment = payload['attachment']; + if (payloadAttachment is! Map) { throw StateError('Missing attachment in /agent/attachments response'); } - final bucket = attachment['bucket']; - final path = attachment['path']; - final uploadedMime = attachment['mimeType']; - final url = attachment['url']; + final bucket = payloadAttachment['bucket']; + final path = payloadAttachment['path']; + final uploadedMime = payloadAttachment['mimeType']; + final url = payloadAttachment['url']; if (bucket is! String || path is! String || uploadedMime is! String || @@ -463,30 +416,15 @@ class AgUiService { url.isEmpty) { throw StateError('Invalid attachment reference'); } - attachments.add( + uploaded.add( UploadedAttachment( - localPath: image.path, + localPath: attachment.localPath, url: url, mimeType: uploadedMime, ), ); } - return attachments; - } - - String _buildHistoryPath({DateTime? beforeDate}) { - final query = []; - if (_threadId != null && _threadId!.isNotEmpty) { - query.add('threadId=$_threadId'); - } - if (beforeDate != null) { - final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day); - query.add('before=${day.toIso8601String().substring(0, 10)}'); - } - if (query.isEmpty) { - return '/api/v1/agent/history'; - } - return '/api/v1/agent/history?${query.join('&')}'; + return uploaded; } String _nextId(String prefix) => diff --git a/apps/lib/core/chat/chat_api.dart b/apps/lib/core/chat/chat_api.dart new file mode 100644 index 0000000..5d77de7 --- /dev/null +++ b/apps/lib/core/chat/chat_api.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; + +abstract class ChatApi { + Future> createRun(Map runInput); + + Future> streamRunEvents( + String threadId, { + String? lastEventId, + }); + + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }); + + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }); + + Future fetchAttachmentPreview(String previewPath); + + Future transcribeAudio(String filePath); + + Future cancelRun({required String threadId, required String runId}); +} diff --git a/apps/lib/features/chat/data/repositories/chat_history_repository.dart b/apps/lib/core/chat/chat_history_repository.dart similarity index 71% rename from apps/lib/features/chat/data/repositories/chat_history_repository.dart rename to apps/lib/core/chat/chat_history_repository.dart index 94ad51e..3a9a06d 100644 --- a/apps/lib/features/chat/data/repositories/chat_history_repository.dart +++ b/apps/lib/core/chat/chat_history_repository.dart @@ -1,12 +1,12 @@ -import 'package:social_app/data/network/i_api_client.dart'; +import 'chat_api.dart'; import 'package:social_app/data/cache/cache_policy.dart'; import 'package:social_app/data/cache/cached_repository.dart'; -import '../models/ag_ui_event.dart'; +import 'ag_ui_event.dart'; class ChatHistoryRepository extends CachedRepository { - ChatHistoryRepository({required IApiClient apiClient, required super.store}) - : _apiClient = apiClient, + ChatHistoryRepository({required ChatApi chatApi, required super.store}) + : _chatApi = chatApi, super( policy: const CachePolicy( softTtl: Duration(seconds: 30), @@ -17,7 +17,7 @@ class ChatHistoryRepository extends CachedRepository { decodeValue: _decodeSnapshot, ); - final IApiClient _apiClient; + final ChatApi _chatApi; Future loadHistory({ String? threadId, @@ -37,30 +37,13 @@ class ChatHistoryRepository extends CachedRepository { String? threadId, DateTime? beforeDate, }) async { - final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate); - final response = await _apiClient.get>(path); - final payload = response.data; - if (payload is! Map) { - throw StateError('Invalid /agent/history response'); - } + final payload = await _chatApi.fetchHistory( + threadId: threadId, + beforeDate: beforeDate, + ); return HistorySnapshot.fromJson(payload); } - static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) { - final query = []; - if (threadId != null && threadId.isNotEmpty) { - query.add('threadId=$threadId'); - } - if (beforeDate != null) { - final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day); - query.add('before=${day.toIso8601String().substring(0, 10)}'); - } - if (query.isEmpty) { - return '/api/v1/agent/history'; - } - return '/api/v1/agent/history?${query.join('&')}'; - } - static String _keyFor({String? threadId, DateTime? beforeDate}) { final threadPart = (threadId == null || threadId.isEmpty) ? 'default' diff --git a/apps/lib/features/chat/data/apis/chat_api_impl.dart b/apps/lib/features/chat/data/apis/chat_api_impl.dart new file mode 100644 index 0000000..9894832 --- /dev/null +++ b/apps/lib/features/chat/data/apis/chat_api_impl.dart @@ -0,0 +1,142 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:social_app/core/chat/chat_api.dart'; +import 'package:social_app/data/network/i_api_client.dart'; + +class ChatApiImpl implements ChatApi { + ChatApiImpl(this._apiClient); + + final IApiClient _apiClient; + + @override + Future> createRun(Map runInput) async { + final response = await _apiClient.post>( + '/api/v1/agent/runs', + data: runInput, + ); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /agent/runs response'); + } + return payload; + } + + @override + Future> streamRunEvents( + String threadId, { + String? lastEventId, + }) { + final headers = {'Accept': 'text/event-stream'}; + if (lastEventId != null && lastEventId.isNotEmpty) { + headers['Last-Event-ID'] = lastEventId; + } + return _apiClient.getSseLines( + '/api/v1/agent/runs/$threadId/events', + headers: headers, + ); + } + + @override + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }) async { + final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate); + final response = await _apiClient.get>(path); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /agent/history response'); + } + return payload; + } + + @override + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) async { + final formData = FormData.fromMap({ + 'threadId': threadId, + 'file': MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: DioMediaType.parse(mimeType), + ), + }); + final response = await _apiClient.post>( + '/api/v1/agent/attachments', + data: formData, + ); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /agent/attachments response'); + } + return payload; + } + + @override + Future fetchAttachmentPreview(String previewPath) async { + final response = await _apiClient.get>( + previewPath, + options: Options(responseType: ResponseType.bytes), + ); + final payload = response.data; + if (payload is! List) { + throw StateError('Invalid attachment payload'); + } + return Uint8List.fromList(payload); + } + + @override + Future transcribeAudio(String filePath) async { + final formData = FormData.fromMap({ + 'audio': await MultipartFile.fromFile( + filePath, + filename: 'recording.wav', + contentType: DioMediaType('audio', '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; + } + + @override + Future cancelRun({ + required String threadId, + required String runId, + }) async { + final encodedRunId = Uri.encodeQueryComponent(runId); + await _apiClient.post>( + '/api/v1/agent/runs/$threadId/cancel?runId=$encodedRunId', + ); + } + + static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) { + final query = []; + if (threadId != null && threadId.isNotEmpty) { + query.add('threadId=${Uri.encodeQueryComponent(threadId)}'); + } + if (beforeDate != null) { + final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day); + query.add('before=${day.toIso8601String().substring(0, 10)}'); + } + if (query.isEmpty) { + return '/api/v1/agent/history'; + } + return '/api/v1/agent/history?${query.join('&')}'; + } +} diff --git a/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart b/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart index 19aad14..22367a2 100644 --- a/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart +++ b/apps/lib/features/chat/presentation/bloc/ag_ui_event_label.dart @@ -1,5 +1,5 @@ import '../../../../core/l10n/l10n.dart'; -import '../../data/models/ag_ui_event.dart'; +import '../../../../core/chat/ag_ui_event.dart'; String agUiEventLabel(AgUiEventType type) { final l10n = L10n.current; diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 2f1aa39..6504fed 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -2,16 +2,15 @@ import 'dart:typed_data'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:social_app/core/chat/chat_api.dart'; import 'package:social_app/core/chat/agent_stage.dart'; +import 'package:social_app/core/chat/ag_ui_event.dart'; +import 'package:social_app/core/chat/ag_ui_service.dart'; import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/chat/chat_orchestrator.dart'; -import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/core/l10n/l10n.dart'; -import '../../data/models/ag_ui_event.dart'; -import '../../data/repositories/chat_history_repository.dart'; -import '../../data/services/ag_ui_service.dart'; - class ChatState implements ChatOrchestratorState { @override final List items; @@ -98,14 +97,11 @@ class ChatState implements ChatOrchestratorState { class ChatBloc extends Cubit implements ChatOrchestrator { ChatBloc({ AgUiService? service, - required IApiClient apiClient, + required ChatApi chatApi, ChatHistoryRepository? historyRepository, }) : _service = service ?? - AgUiService( - apiClient: apiClient, - historyRepository: historyRepository, - ), + AgUiService(chatApi: chatApi, historyRepository: historyRepository), super(const ChatState()) { _service.onEvent = _handleEvent; } @@ -434,7 +430,20 @@ class ChatBloc extends Cubit implements ChatOrchestrator { ), ); try { - final sendResult = await _service.sendMessage(content, images: images); + final uploadInputs = await Future.wait( + (images ?? const []).map( + (image) async => AttachmentUploadInput( + name: image.name, + mimeType: image.mimeType ?? 'image/jpeg', + bytes: await image.readAsBytes(), + localPath: image.path, + ), + ), + ); + final sendResult = await _service.sendMessage( + content, + attachments: uploadInputs, + ); _syncUploadedAttachments( messageId: messageId, uploadedAttachments: sendResult.uploadedAttachments, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 8827b99..5560fa5 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -393,12 +393,6 @@ class _HomeScreenState extends State try { if (hasEarlierHistory) { await _loadMoreHistoryPreservingViewport(chatBloc); - } else { - Toast.show( - context, - context.l10n.homeNoEarlierHistory, - type: ToastType.info, - ); } _applyViewportDecision( _dispatchViewportEvent( @@ -687,29 +681,6 @@ class _HomeEmptyStateAmbient extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Center( - child: IgnorePointer( - child: Container( - key: homeEmptyStateAmbientKey, - width: double.infinity, - height: AppSpacing.xxl * 6, - margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - colorScheme.primaryContainer.withValues(alpha: 0.12), - colorScheme.primary.withValues(alpha: 0.08), - colorScheme.primaryContainer.withValues(alpha: 0.12), - ], - ), - ), - ), - ), - ); + return const SizedBox.shrink(key: homeEmptyStateAmbientKey); } } diff --git a/apps/lib/features/home/presentation/widgets/home_background_field.dart b/apps/lib/features/home/presentation/widgets/home_background_field.dart index d1f702b..65f8512 100644 --- a/apps/lib/features/home/presentation/widgets/home_background_field.dart +++ b/apps/lib/features/home/presentation/widgets/home_background_field.dart @@ -1,10 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../core/theme/design_tokens.dart'; - const homeBackgroundFieldKey = ValueKey('home_background_field'); -const homeTopGlowKey = ValueKey('home_top_glow'); -const homeBottomGlowKey = ValueKey('home_bottom_glow'); class HomeBackgroundField extends StatelessWidget { const HomeBackgroundField({super.key}); @@ -22,72 +18,7 @@ class HomeBackgroundField extends StatelessWidget { colors: [colorScheme.surface, colorScheme.surfaceContainerLowest], ), ), - child: const Stack(children: [_HomeTopGlow(), _HomeBottomGlow()]), - ); - } -} - -class _HomeTopGlow extends StatelessWidget { - const _HomeTopGlow(); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Align( - alignment: const Alignment(-0.25, -0.9), - child: IgnorePointer( - child: Container( - key: homeTopGlowKey, - width: AppSpacing.xxl * 10, - height: AppSpacing.xxl * 7, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppSpacing.xxl * 3), - color: colorScheme.primaryContainer.withValues(alpha: 0.28), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withValues(alpha: 0.2), - blurRadius: AppSpacing.xxl * 3, - spreadRadius: AppSpacing.xl, - ), - ], - ), - ), - ), - ); - } -} - -class _HomeBottomGlow extends StatelessWidget { - const _HomeBottomGlow(); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return IgnorePointer( - child: Align( - alignment: Alignment.bottomCenter, - child: Transform.translate( - offset: const Offset(0, AppSpacing.lg), - child: Container( - key: homeBottomGlowKey, - width: AppSpacing.xxl * 12, - height: AppSpacing.xxl * 3, - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withValues(alpha: 0.1), - blurRadius: AppSpacing.xxl, - spreadRadius: AppSpacing.sm, - ), - ], - ), - ), - ), - ), + child: const SizedBox.expand(), ); } } diff --git a/apps/test/app/router/app_router_redirect_test.dart b/apps/test/app/router/app_router_redirect_test.dart index 8d3d175..813d505 100644 --- a/apps/test/app/router/app_router_redirect_test.dart +++ b/apps/test/app/router/app_router_redirect_test.dart @@ -1,10 +1,11 @@ -import 'package:dio/dio.dart'; +import 'dart:typed_data'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/app/di/injection.dart'; import 'package:social_app/app/router/app_router.dart'; import 'package:social_app/app/router/app_routes.dart'; -import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/core/chat/chat_api.dart'; import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; @@ -13,7 +14,7 @@ void main() { if (sl.isRegistered()) { await sl.unregister(); } - sl.registerSingleton(ChatBloc(apiClient: _FakeApiClient())); + sl.registerSingleton(ChatBloc(chatApi: _FakeChatApi())); }); tearDown(() async { @@ -43,6 +44,17 @@ void main() { expect(result, AppRoutes.homeMain); }); + test('keeps authenticated home access on home route', () { + final result = resolveAuthRedirect( + authState: const AuthAuthenticated( + user: AuthUser(id: 'u1', phone: '13800138000'), + ), + matchedLocation: AppRoutes.homeMain, + ); + + expect(result, isNull); + }); + test('redirects auth checking state to boot route', () { final result = resolveAuthRedirect( authState: AuthLoading(), @@ -69,37 +81,50 @@ void main() { }); } -class _FakeApiClient implements IApiClient { +class _FakeChatApi implements ChatApi { @override - Future> delete(String path, {data, Options? options}) { + Future cancelRun({required String threadId, required String runId}) { throw UnimplementedError(); } @override - Future> get(String path, {Options? options}) { + Future> createRun(Map runInput) { throw UnimplementedError(); } @override - Future> getSseLines( - String path, { - Map? headers, + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, }) { throw UnimplementedError(); } @override - Future> patch(String path, {data, Options? options}) { + Future fetchAttachmentPreview(String previewPath) { throw UnimplementedError(); } @override - Future> post(String path, {data, Options? options}) { + Future> streamRunEvents( + String threadId, { + String? lastEventId, + }) { throw UnimplementedError(); } @override - Future> put(String path, {data, Options? options}) { + Future transcribeAudio(String filePath) { + throw UnimplementedError(); + } + + @override + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) { throw UnimplementedError(); } } diff --git a/apps/test/app/services/app_prewarm_orchestrator_test.dart b/apps/test/app/services/app_prewarm_orchestrator_test.dart index 815d0c2..1901f5f 100644 --- a/apps/test/app/services/app_prewarm_orchestrator_test.dart +++ b/apps/test/app/services/app_prewarm_orchestrator_test.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/app/services/app_prewarm_orchestrator.dart'; +import 'package:social_app/core/chat/chat_api.dart'; +import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/data/network/i_api_client.dart'; import 'package:social_app/data/cache/cache_store.dart'; import 'package:social_app/features/calendar/data/repositories/calendar_repository.dart'; import 'package:social_app/features/messages/data/repositories/inbox_repository.dart'; -import 'package:social_app/features/chat/data/repositories/chat_history_repository.dart'; class _FakeApiClient implements IApiClient { @override @@ -44,15 +46,65 @@ class _FakeApiClient implements IApiClient { } } +class _FakeChatApi implements ChatApi { + @override + Future cancelRun({required String threadId, required String runId}) { + throw UnimplementedError(); + } + + @override + Future> createRun(Map runInput) { + throw UnimplementedError(); + } + + @override + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }) { + throw UnimplementedError(); + } + + @override + Future fetchAttachmentPreview(String previewPath) { + throw UnimplementedError(); + } + + @override + Future> streamRunEvents( + String threadId, { + String? lastEventId, + }) { + throw UnimplementedError(); + } + + @override + Future transcribeAudio(String filePath) { + throw UnimplementedError(); + } + + @override + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) { + throw UnimplementedError(); + } +} + void main() { late HybridCacheStore store; late _FakeApiClient apiClient; + late _FakeChatApi chatApi; late CalendarRepository calendarRepository; late InboxRepository inboxRepository; late ChatHistoryRepository chatHistoryRepository; setUp(() { apiClient = _FakeApiClient(); + chatApi = _FakeChatApi(); store = HybridCacheStore( memory: MemoryCacheStore(), persistent: PersistentCacheStore(), @@ -60,7 +112,7 @@ void main() { calendarRepository = CalendarRepository(apiClient: apiClient, store: store); inboxRepository = InboxRepositoryImpl(apiClient: apiClient, store: store); chatHistoryRepository = ChatHistoryRepository( - apiClient: apiClient, + chatApi: chatApi, store: store, ); }); diff --git a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart index 433468c..ebbe911 100644 --- a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart +++ b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart @@ -1,60 +1,87 @@ -import 'package:dio/dio.dart'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/core/chat/chat_api.dart'; +import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/data/cache/cache_store.dart'; -import 'package:social_app/features/chat/data/repositories/chat_history_repository.dart'; -class _FakeApiClient implements IApiClient { - final Map _getResponses = {}; - final Map getCalls = {}; +class _FakeChatApi implements ChatApi { + final Map _historyResponses = {}; + final Map historyCalls = {}; - void setGet(String path, dynamic data) => _getResponses[path] = data; + void setHistory(String key, dynamic data) => _historyResponses[key] = data; @override - Future> get(String path, {Options? options}) async { - getCalls[path] = (getCalls[path] ?? 0) + 1; - if (!_getResponses.containsKey(path)) { - throw StateError('missing GET mock for $path'); + Future> fetchHistory({ + String? threadId, + DateTime? beforeDate, + }) async { + final key = _historyKey(threadId: threadId, beforeDate: beforeDate); + historyCalls[key] = (historyCalls[key] ?? 0) + 1; + if (!_historyResponses.containsKey(key)) { + throw StateError('missing history mock for $key'); } - return Response( - requestOptions: RequestOptions(path: path), - data: _getResponses[path] as T, - ); + return _historyResponses[key] as Map; } @override - Future> delete(String path, {data, Options? options}) { + Future cancelRun({required String threadId, required String runId}) { throw UnimplementedError(); } @override - Future> getSseLines( - String path, { - Map? headers, + Future> createRun(Map runInput) { + throw UnimplementedError(); + } + + @override + Future fetchAttachmentPreview(String previewPath) { + throw UnimplementedError(); + } + + @override + Future> streamRunEvents( + String threadId, { + String? lastEventId, }) { throw UnimplementedError(); } @override - Future> patch(String path, {data, Options? options}) { + Future transcribeAudio(String filePath) { throw UnimplementedError(); } @override - Future> post(String path, {data, Options? options}) { + Future> uploadAttachment({ + required String threadId, + required String filename, + required String mimeType, + required Uint8List bytes, + }) { throw UnimplementedError(); } - @override - Future> put(String path, {data, Options? options}) { - throw UnimplementedError(); + String _historyKey({String? threadId, DateTime? beforeDate}) { + final threadPart = (threadId == null || threadId.isEmpty) + ? 'default' + : threadId; + if (beforeDate == null) { + return 'first:$threadPart'; + } + final day = DateTime( + beforeDate.year, + beforeDate.month, + beforeDate.day, + ).toIso8601String().substring(0, 10); + return 'before:$threadPart:$day'; } } void main() { test('loads first-page history from cache on second read', () async { - final client = _FakeApiClient(); - client.setGet('/api/v1/agent/history', { + final chatApi = _FakeChatApi(); + chatApi.setHistory('first:default', { 'scope': 'history_day', 'threadId': 't1', 'day': '2026-03-29', @@ -72,7 +99,7 @@ void main() { }); final repository = ChatHistoryRepository( - apiClient: client, + chatApi: chatApi, store: HybridCacheStore( memory: MemoryCacheStore(), persistent: PersistentCacheStore(), @@ -84,6 +111,6 @@ void main() { expect(first.threadId, 't1'); expect(second.messages.length, 1); - expect(client.getCalls['/api/v1/agent/history'], 1); + expect(chatApi.historyCalls['first:default'], 1); }); } diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index ba714ff..12eec4e 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -24,7 +24,6 @@ from fastapi import ( File, Form, Header, - HTTPException, Query, Request, UploadFile, @@ -195,16 +194,22 @@ async def stream_events( if last_event_id is not None and ( len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None ): - raise HTTPException( + raise ApiProblemError( status_code=422, - detail="Invalid Last-Event-ID", + detail=problem_payload( + code="AGENT_INVALID_LAST_EVENT_ID", + detail="Invalid Last-Event-ID", + ), ) sse_slot_acquired = await _acquire_sse_slot(user_id=str(current_user.id)) if not sse_slot_acquired: - raise HTTPException( + raise ApiProblemError( status_code=429, - detail="Too many SSE connections", + detail=problem_payload( + code="AGENT_SSE_CONNECTION_LIMIT", + detail="Too many SSE connections", + ), ) async def _event_iter() -> AsyncIterator[str]: @@ -296,14 +301,21 @@ async def upload_attachment( ) -> AttachmentUploadResponse: payload = await file.read() if not payload: - raise HTTPException( + raise ApiProblemError( status_code=422, - detail="Empty attachment", + detail=problem_payload( + code="AGENT_ATTACHMENT_EMPTY", + detail="Empty attachment", + ), ) if len(payload) > _MAX_ATTACHMENT_UPLOAD_BYTES: - raise HTTPException( + raise ApiProblemError( status_code=413, - detail="Attachment too large", + detail=problem_payload( + code="AGENT_ATTACHMENT_TOO_LARGE", + detail="Attachment too large", + params={"maxBytes": _MAX_ATTACHMENT_UPLOAD_BYTES}, + ), ) attachment = await service.upload_attachment( thread_id=thread_id, @@ -388,9 +400,13 @@ async def transcribe( break total_bytes += len(chunk) if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="Audio file too large", + detail=problem_payload( + code="AGENT_AUDIO_TOO_LARGE", + detail="Audio file too large", + params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES}, + ), ) if len(header) < _WAV_HEADER_MIN_BYTES: required = _WAV_HEADER_MIN_BYTES - len(header) @@ -398,14 +414,20 @@ async def transcribe( tmp_file.write(chunk) if total_bytes == 0: - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="Empty audio file", + detail=problem_payload( + code="AGENT_AUDIO_EMPTY", + detail="Empty audio file", + ), ) if not _looks_like_wav_header(bytes(header)): - raise HTTPException( + raise ApiProblemError( status_code=400, - detail="Unsupported audio format", + detail=problem_payload( + code="AGENT_AUDIO_UNSUPPORTED_FORMAT", + detail="Unsupported audio format", + ), ) transcript = await asr_service.transcribe_file( @@ -414,7 +436,7 @@ async def transcribe( return AsrTranscribeResponse(transcript=transcript) - except HTTPException: + except ApiProblemError: raise except RuntimeError: raise ApiProblemError( diff --git a/backend/tests/integration/v1/agent/test_routes.py b/backend/tests/integration/v1/agent/test_routes.py index 64820c9..cf63492 100644 --- a/backend/tests/integration/v1/agent/test_routes.py +++ b/backend/tests/integration/v1/agent/test_routes.py @@ -313,10 +313,40 @@ def test_stream_rejects_invalid_last_event_id() -> None: headers={"Last-Event-ID": "bad-id"}, ) assert response.status_code == 422 + payload = response.json() + assert payload["code"] == "AGENT_INVALID_LAST_EVENT_ID" + assert payload["detail"] == "Invalid Last-Event-ID" finally: app.dependency_overrides = {} +def test_stream_rejects_when_sse_connection_limit_exceeded() -> None: + app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=uuid4(), phone="+8613812345678" + ) + client = TestClient(app) + original_acquire = agent_router._acquire_sse_slot + + async def _deny_slot(*, user_id: str) -> bool: + del user_id + return False + + agent_router._acquire_sse_slot = _deny_slot # type: ignore[assignment] + + try: + response = client.get( + "/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/events" + ) + assert response.status_code == 429 + payload = response.json() + assert payload["code"] == "AGENT_SSE_CONNECTION_LIMIT" + assert payload["detail"] == "Too many SSE connections" + finally: + agent_router._acquire_sse_slot = original_acquire # type: ignore[assignment] + app.dependency_overrides = {} + + def test_cancel_run_returns_202_and_payload() -> None: service = _FakeAgentService() app.dependency_overrides[get_agent_service] = lambda: service @@ -470,6 +500,58 @@ def test_upload_attachment_returns_reference() -> None: app.dependency_overrides = {} +def test_upload_attachment_rejects_empty_payload_with_problem_details() -> None: + app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=uuid4(), phone="+8613812345678" + ) + client = TestClient(app) + + empty_payload = BytesIO(b"") + empty_payload.name = "empty.png" + + try: + response = client.post( + "/api/v1/agent/attachments", + data={"threadId": "00000000-0000-0000-0000-000000000001"}, + files={"file": ("empty.png", empty_payload, "image/png")}, + ) + assert response.status_code == 422 + payload = response.json() + assert payload["code"] == "AGENT_ATTACHMENT_EMPTY" + assert payload["detail"] == "Empty attachment" + finally: + app.dependency_overrides = {} + + +def test_upload_attachment_rejects_oversized_payload_with_problem_details( + monkeypatch, +) -> None: + app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=uuid4(), phone="+8613812345678" + ) + client = TestClient(app) + + monkeypatch.setattr(agent_router, "_MAX_ATTACHMENT_UPLOAD_BYTES", 3) + file_payload = BytesIO(b"1234") + file_payload.name = "oversize.png" + + try: + response = client.post( + "/api/v1/agent/attachments", + data={"threadId": "00000000-0000-0000-0000-000000000001"}, + files={"file": ("oversize.png", file_payload, "image/png")}, + ) + assert response.status_code == 413 + payload = response.json() + assert payload["code"] == "AGENT_ATTACHMENT_TOO_LARGE" + assert payload["detail"] == "Attachment too large" + assert payload["params"]["maxBytes"] == 3 + finally: + app.dependency_overrides = {} + + def test_create_attachment_signed_url_returns_url() -> None: app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() app.dependency_overrides[get_current_user] = lambda: CurrentUser( @@ -548,6 +630,7 @@ def test_asr_transcribe_rejects_oversized_audio(monkeypatch) -> None: assert response.status_code == 400 assert response.json()["detail"] == "Audio file too large" + assert response.json()["code"] == "AGENT_AUDIO_TOO_LARGE" finally: app.dependency_overrides = {} @@ -569,6 +652,29 @@ def test_asr_transcribe_rejects_non_wav_audio(monkeypatch) -> None: assert response.status_code == 400 assert response.json()["detail"] == "Unsupported audio format" + assert response.json()["code"] == "AGENT_AUDIO_UNSUPPORTED_FORMAT" + finally: + app.dependency_overrides = {} + + +def test_asr_transcribe_rejects_empty_wav_payload() -> None: + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=uuid4(), phone="+8613812345678" + ) + + client = TestClient(app) + empty_wav = BytesIO(b"") + empty_wav.name = "empty.wav" + + try: + response = client.post( + "/api/v1/agent/transcribe", + files={"audio": ("empty.wav", empty_wav, "audio/wav")}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Empty audio file" + assert response.json()["code"] == "AGENT_AUDIO_EMPTY" finally: app.dependency_overrides = {} @@ -590,5 +696,6 @@ def test_asr_transcribe_rejects_invalid_wav_payload(monkeypatch) -> None: assert response.status_code == 400 assert response.json()["detail"] == "Unsupported audio format" + assert response.json()["code"] == "AGENT_AUDIO_UNSUPPORTED_FORMAT" finally: app.dependency_overrides = {}