refactor: 重构聊天数据层至core并简化首页UI
This commit is contained in:
+10
-1
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
+55
-117
@@ -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) =>
|
||||
@@ -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});
|
||||
}
|
||||
+9
-26
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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=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=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=problem_payload(
|
||||
code="AGENT_ATTACHMENT_EMPTY",
|
||||
detail="Empty attachment",
|
||||
),
|
||||
)
|
||||
if len(payload) > _MAX_ATTACHMENT_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
raise ApiProblemError(
|
||||
status_code=413,
|
||||
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=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=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=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 = {}
|
||||
|
||||
Reference in New Issue
Block a user