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
+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(),
);
}
}