refactor: 重构聊天数据层至core并简化首页UI

This commit is contained in:
zl-q
2026-03-29 21:46:26 +08:00
parent 4db9a13bfe
commit f126d7a547
18 changed files with 568 additions and 328 deletions
+10 -1
View File
@@ -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.
+8 -3
View File
@@ -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<void> configureDependencies() async {
InboxRepositoryImpl(apiClient: apiClient, store: hybridCacheStore),
);
final chatApi = ChatApiImpl(apiClient);
sl.registerSingleton<ChatApi>(chatApi);
final chatHistoryRepository = ChatHistoryRepository(
apiClient: apiClient,
chatApi: chatApi,
store: hybridCacheStore,
);
sl.registerSingleton<ChatHistoryRepository>(chatHistoryRepository);
@@ -167,7 +172,7 @@ Future<void> configureDependencies() async {
sl.registerSingleton<AuthBloc>(authBloc);
sl.registerSingleton<SessionController>(AuthSessionController(authBloc));
sl.registerSingleton<ChatBloc>(
ChatBloc(apiClient: apiClient, historyRepository: chatHistoryRepository),
ChatBloc(chatApi: chatApi, historyRepository: chatHistoryRepository),
);
apiClient.setRefreshCallback((token) async {
+1 -10
View File
@@ -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) {
@@ -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 }
@@ -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<String, String> _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<SendMessageResult> sendMessage(
String content, {
List<XFile>? images,
List<AttachmentUploadInput>? attachments,
}) async {
await _cancelActiveSseSubscription();
final streamToken = ++_activeStreamToken;
final runInputPayload = await _buildRunInput(
content: content,
images: images,
attachments: attachments,
);
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
data: runInputPayload.input,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
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<HistorySnapshot> _loadHistoryFromApi({DateTime? beforeDate}) async {
final path = _buildHistoryPath(beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
final payload = await _chatApi.fetchHistory(
threadId: _threadId,
beforeDate: beforeDate,
);
return HistorySnapshot.fromJson(payload);
}
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
final response = await _apiClient.get<List<int>>(
previewPath,
options: Options(responseType: ResponseType.bytes),
);
final payload = response.data;
if (payload is List<int>) {
return Uint8List.fromList(payload);
}
throw StateError('Invalid attachment payload');
}
Future<Uint8List> fetchAttachmentPreview(String previewPath) =>
_chatApi.fetchAttachmentPreview(previewPath);
Future<String> 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<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
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<String> 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<Map<String, dynamic>>(
'/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 = <String, String>{'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<XFile>? images,
List<AttachmentUploadInput>? attachments,
}) async {
final threadId = _threadId ?? _newUuid();
final runId = _nextId(_runIdPrefix);
@@ -381,10 +346,10 @@ class AgUiService {
}
var uploadedAttachments = const <UploadedAttachment>[];
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<List<UploadedAttachment>> _uploadAttachments({
required String threadId,
required List<XFile> images,
required List<AttachmentUploadInput> attachments,
}) async {
final attachments = <UploadedAttachment>[];
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<Map<String, dynamic>>(
'/api/v1/agent/attachments',
data: formData,
final uploaded = <UploadedAttachment>[];
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<String, dynamic>) {
throw StateError('Invalid /agent/attachments response');
}
final attachment = payload['attachment'];
if (attachment is! Map<String, dynamic>) {
final payloadAttachment = payload['attachment'];
if (payloadAttachment is! Map<String, dynamic>) {
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 = <String>[];
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) =>
+28
View File
@@ -0,0 +1,28 @@
import 'dart:typed_data';
abstract class ChatApi {
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput);
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
});
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
});
Future<Map<String, dynamic>> uploadAttachment({
required String threadId,
required String filename,
required String mimeType,
required Uint8List bytes,
});
Future<Uint8List> fetchAttachmentPreview(String previewPath);
Future<String> transcribeAudio(String filePath);
Future<void> cancelRun({required String threadId, required String runId});
}
@@ -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<HistorySnapshot> {
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<HistorySnapshot> {
decodeValue: _decodeSnapshot,
);
final IApiClient _apiClient;
final ChatApi _chatApi;
Future<HistorySnapshot> loadHistory({
String? threadId,
@@ -37,30 +37,13 @@ class ChatHistoryRepository extends CachedRepository<HistorySnapshot> {
String? threadId,
DateTime? beforeDate,
}) async {
final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
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 = <String>[];
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'
@@ -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<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
data: runInput,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/runs response');
}
return payload;
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
}) {
final headers = <String, String>{'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<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) async {
final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
return payload;
}
@override
Future<Map<String, dynamic>> 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<Map<String, dynamic>>(
'/api/v1/agent/attachments',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/attachments response');
}
return payload;
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
final response = await _apiClient.get<List<int>>(
previewPath,
options: Options(responseType: ResponseType.bytes),
);
final payload = response.data;
if (payload is! List<int>) {
throw StateError('Invalid attachment payload');
}
return Uint8List.fromList(payload);
}
@override
Future<String> 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<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
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<void> cancelRun({
required String threadId,
required String runId,
}) async {
final encodedRunId = Uri.encodeQueryComponent(runId);
await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs/$threadId/cancel?runId=$encodedRunId',
);
}
static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) {
final query = <String>[];
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('&')}';
}
}
@@ -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;
@@ -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<ChatListItem> items;
@@ -98,14 +97,11 @@ class ChatState implements ChatOrchestratorState {
class ChatBloc extends Cubit<ChatState> 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<ChatState> implements ChatOrchestrator {
),
);
try {
final sendResult = await _service.sendMessage(content, images: images);
final uploadInputs = await Future.wait(
(images ?? const <XFile>[]).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,
@@ -393,12 +393,6 @@ class _HomeScreenState extends State<HomeScreen>
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);
}
}
@@ -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(),
);
}
}
@@ -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<ChatBloc>()) {
await sl.unregister<ChatBloc>();
}
sl.registerSingleton<ChatBloc>(ChatBloc(apiClient: _FakeApiClient()));
sl.registerSingleton<ChatBloc>(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<Response<T>> delete<T>(String path, {data, Options? options}) {
Future<void> cancelRun({required String threadId, required String runId}) {
throw UnimplementedError();
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) {
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
throw UnimplementedError();
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) {
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> put<T>(String path, {data, Options? options}) {
Future<String> transcribeAudio(String filePath) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> uploadAttachment({
required String threadId,
required String filename,
required String mimeType,
required Uint8List bytes,
}) {
throw UnimplementedError();
}
}
@@ -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<void> cancelRun({required String threadId, required String runId}) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) {
throw UnimplementedError();
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
throw UnimplementedError();
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
}) {
throw UnimplementedError();
}
@override
Future<String> transcribeAudio(String filePath) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> 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,
);
});
@@ -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<String, dynamic> _getResponses = <String, dynamic>{};
final Map<String, int> getCalls = <String, int>{};
class _FakeChatApi implements ChatApi {
final Map<String, dynamic> _historyResponses = <String, dynamic>{};
final Map<String, int> historyCalls = <String, int>{};
void setGet(String path, dynamic data) => _getResponses[path] = data;
void setHistory(String key, dynamic data) => _historyResponses[key] = data;
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
getCalls[path] = (getCalls[path] ?? 0) + 1;
if (!_getResponses.containsKey(path)) {
throw StateError('missing GET mock for $path');
Future<Map<String, dynamic>> 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<T>(
requestOptions: RequestOptions(path: path),
data: _getResponses[path] as T,
);
return _historyResponses[key] as Map<String, dynamic>;
}
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
Future<void> cancelRun({required String threadId, required String runId}) {
throw UnimplementedError();
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
throw UnimplementedError();
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
throw UnimplementedError();
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
Future<String> transcribeAudio(String filePath) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) {
Future<Map<String, dynamic>> uploadAttachment({
required String threadId,
required String filename,
required String mimeType,
required Uint8List bytes,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> put<T>(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);
});
}
+38 -16
View File
@@ -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(
@@ -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 = {}