diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 8a78a4d..27d5878 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -13,6 +13,34 @@ typedef EventCallback = void Function(AgUiEvent event); const _runIdPrefix = 'run_'; +class UploadedAttachment { + const UploadedAttachment({ + required this.localPath, + required this.url, + required this.mimeType, + }); + + final String localPath; + final String url; + final String mimeType; +} + +class SendMessageResult { + const SendMessageResult({required this.uploadedAttachments}); + + final List uploadedAttachments; +} + +class _RunInputPayload { + const _RunInputPayload({ + required this.input, + required this.uploadedAttachments, + }); + + final Map input; + final List uploadedAttachments; +} + class AgUiService { final IApiClient _apiClient; EventCallback onEvent; @@ -26,23 +54,40 @@ class AgUiService { : onEvent = onEvent ?? ((_) {}), _apiClient = apiClient; - Future sendMessage(String content, {List? images}) async { + Future sendMessage( + String content, { + List? images, + }) async { final streamToken = ++_activeStreamToken; - final runInput = await _buildRunInput(content: content, images: images); + final runInputPayload = await _buildRunInput( + content: content, + images: images, + ); final response = await _apiClient.post>( '/api/v1/agent/runs', - data: runInput, + data: runInputPayload.input, ); final payload = response.data; if (payload is! Map) { throw StateError('Invalid /agent/runs response'); } final threadId = payload['threadId'] as String?; + final runId = payload['runId'] as String?; if (threadId == null || threadId.isEmpty) { throw StateError('Missing threadId in /agent/runs response'); } + if (runId == null || runId.isEmpty) { + throw StateError('Missing runId in /agent/runs response'); + } _threadId = threadId; - await _streamEventsFromApi(threadId, streamToken: streamToken); + await _streamEventsFromApi( + threadId, + expectedRunId: runId, + streamToken: streamToken, + ); + return SendMessageResult( + uploadedAttachments: runInputPayload.uploadedAttachments, + ); } Future loadHistory({DateTime? beforeDate}) async { @@ -108,6 +153,7 @@ class AgUiService { Future _streamEventsFromApi( String threadId, { + required String expectedRunId, required int streamToken, }) async { final lastEventId = _lastEventIdByThread[threadId]; @@ -122,6 +168,7 @@ class AgUiService { String? eventType; String? eventId; + var hasBoundExpectedRun = false; final dataBuffer = StringBuffer(); await for (final line in sseLines) { if (streamToken != _activeStreamToken) { @@ -131,11 +178,32 @@ class AgUiService { if (dataBuffer.isNotEmpty) { final raw = dataBuffer.toString(); dataBuffer.clear(); + Map? decoded; + String? eventRunId; + String? eventThreadId; try { - final decoded = jsonDecode(raw); - if (decoded is Map) { - final event = AgUiEvent.fromJson(decoded); - onEvent(event); + final parsed = jsonDecode(raw); + if (parsed is Map) { + decoded = parsed; + final runId = parsed['runId']; + final thread = parsed['threadId']; + eventRunId = runId is String ? runId : null; + eventThreadId = thread is String ? thread : null; + + final isRunStarted = eventType == AgUiEventTypeWire.runStarted; + final isTargetRun = eventRunId == expectedRunId; + if (isRunStarted && isTargetRun) { + hasBoundExpectedRun = true; + } + + final isThreadMatched = + eventThreadId == null || eventThreadId == threadId; + final shouldDispatch = + isTargetRun || (hasBoundExpectedRun && isThreadMatched); + if (shouldDispatch) { + final event = AgUiEvent.fromJson(parsed); + onEvent(event); + } } } catch (_) { // Ignore malformed SSE payload and keep stream alive. @@ -144,8 +212,14 @@ class AgUiService { if (currentEventId != null && currentEventId.isNotEmpty) { _lastEventIdByThread[threadId] = currentEventId; } - if (eventType == AgUiEventTypeWire.runFinished || - eventType == AgUiEventTypeWire.runError) { + final isTerminalEvent = + eventType == AgUiEventTypeWire.runFinished || + eventType == AgUiEventTypeWire.runError; + final isTargetRun = eventRunId == expectedRunId; + final isThreadMatched = + eventThreadId == null || eventThreadId == threadId; + if (isTerminalEvent && + (isTargetRun || (hasBoundExpectedRun && isThreadMatched))) { break; } } @@ -174,7 +248,7 @@ class AgUiService { } } - Future> _buildRunInput({ + Future<_RunInputPayload> _buildRunInput({ required String content, List? images, }) async { @@ -187,16 +261,17 @@ class AgUiService { contentBlocks.add({'type': 'text', 'text': content}); } + var uploadedAttachments = const []; if (images != null && images.isNotEmpty) { - final uploadedAttachments = await _uploadAttachments( + uploadedAttachments = await _uploadAttachments( threadId: threadId, images: images, ); for (final attachment in uploadedAttachments) { contentBlocks.add({ 'type': 'binary', - 'mimeType': attachment['mimeType'], - 'url': attachment['url'], + 'mimeType': attachment.mimeType, + 'url': attachment.url, }); } } @@ -211,24 +286,27 @@ class AgUiService { messageContent = contentBlocks; } - return { - 'threadId': threadId, - 'runId': runId, - 'state': {}, - 'messages': [ - {'id': _nextId('user_'), 'role': 'user', 'content': messageContent}, - ], - 'tools': >[], - 'context': >[], - 'forwardedProps': {}, - }; + return _RunInputPayload( + input: { + 'threadId': threadId, + 'runId': runId, + 'state': {}, + 'messages': [ + {'id': _nextId('user_'), 'role': 'user', 'content': messageContent}, + ], + 'tools': >[], + 'context': >[], + 'forwardedProps': {}, + }, + uploadedAttachments: uploadedAttachments, + ); } - Future>> _uploadAttachments({ + Future> _uploadAttachments({ required String threadId, required List images, }) async { - final attachments = >[]; + final attachments = []; for (final image in images) { final mimeType = image.mimeType ?? 'image/jpeg'; final fileBytes = await image.readAsBytes(); @@ -266,12 +344,13 @@ class AgUiService { url.isEmpty) { throw StateError('Invalid attachment reference'); } - attachments.add({ - 'bucket': bucket, - 'path': path, - 'mimeType': uploadedMime, - 'url': url, - }); + attachments.add( + UploadedAttachment( + localPath: image.path, + url: url, + mimeType: uploadedMime, + ), + ); } return attachments; } diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 62599f5..ef8e723 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -118,10 +118,16 @@ class ChatBloc extends Cubit { ), ); case AgUiEventType.runFinished: - emit(_resetRunState()); + emit( + _resetRunState().copyWith(items: _removeToolCallItems(state.items)), + ); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; - emit(_resetRunState(error: errorEvent.message)); + emit( + _resetRunState( + error: errorEvent.message, + ).copyWith(items: _markActiveToolCallsFailed(state.items)), + ); case AgUiEventType.stepStarted: _handleStepStarted(event as StepStartedEvent); case AgUiEventType.stepFinished: @@ -167,9 +173,11 @@ class ChatBloc extends Cubit { _upsertUiSchema(items, event.messageId, uiSchema, timestamp); } + final withoutToolCalls = _removeToolCallItems(items); + emit( state.copyWith( - items: items, + items: withoutToolCalls, currentMessageId: null, isWaitingFirstToken: false, isStreaming: false, @@ -264,13 +272,38 @@ class ChatBloc extends Cubit { } void _handleToolCallResult(ToolCallResultEvent event) { - final items = state.items.where((item) { - return !(item is ToolCallItem && item.id == event.toolCallId); + final items = state.items.map((item) { + if (item is ToolCallItem && item.id == event.toolCallId) { + return item.copyWith(status: ToolCallStatus.completed); + } + return item; }).toList(); emit(state.copyWith(items: items)); } + List _removeToolCallItems(List items) { + return items.where((item) => item is! ToolCallItem).toList(); + } + + List _markActiveToolCallsFailed(List items) { + return items.map((item) { + if (item is! ToolCallItem) { + return item; + } + if (item.status == ToolCallStatus.error) { + return item; + } + if (item.status == ToolCallStatus.completed) { + return item; + } + return item.copyWith( + status: ToolCallStatus.error, + errorMessage: '本次运行已失败', + ); + }).toList(); + } + void _handleToolCallError(ToolCallErrorEvent event) { final items = state.items.map((item) { if (item is ToolCallItem && item.id == event.toolCallId) { @@ -341,16 +374,18 @@ class ChatBloc extends Cubit { } Future sendMessage(String content, {List? images}) async { + final messageId = 'user-${DateTime.now().millisecondsSinceEpoch}'; final attachments = (images ?? const []) .map( (image) => { 'path': image.path, - 'mimeType': 'image/*', + 'mimeType': image.mimeType ?? 'image/jpeg', + 'uploading': true, }, ) .toList(); final userMessage = TextMessageItem( - id: 'user-${DateTime.now().millisecondsSinceEpoch}', + id: messageId, content: content, timestamp: DateTime.now(), sender: MessageSender.user, @@ -367,8 +402,13 @@ class ChatBloc extends Cubit { ), ); try { - await _service.sendMessage(content, images: images); + final sendResult = await _service.sendMessage(content, images: images); + _syncUploadedAttachments( + messageId: messageId, + uploadedAttachments: sendResult.uploadedAttachments, + ); } catch (error) { + _markAttachmentUploadDone(messageId); emit( state.copyWith( isSending: false, @@ -381,6 +421,63 @@ class ChatBloc extends Cubit { } } + void _syncUploadedAttachments({ + required String messageId, + required List uploadedAttachments, + }) { + if (uploadedAttachments.isEmpty) { + _markAttachmentUploadDone(messageId); + return; + } + final items = state.items.map((item) { + if (item is! TextMessageItem || item.id != messageId) { + return item; + } + final synced = item.attachments.map((attachment) { + final localPath = attachment['path']; + if (localPath is! String || localPath.isEmpty) { + return {...attachment, 'uploading': false}; + } + UploadedAttachment? matched; + for (final candidate in uploadedAttachments) { + if (candidate.localPath == localPath) { + matched = candidate; + break; + } + } + if (matched == null) { + return {...attachment, 'uploading': false}; + } + return { + ...attachment, + 'url': matched.url, + 'mimeType': matched.mimeType, + 'uploading': false, + }; + }).toList(); + return item.copyWith(attachments: synced); + }).toList(); + emit(state.copyWith(items: items)); + } + + void _markAttachmentUploadDone(String messageId) { + final items = state.items.map((item) { + if (item is! TextMessageItem || item.id != messageId) { + return item; + } + final done = item.attachments + .map( + (attachment) => { + ...attachment, + 'uploading': false, + }, + ) + .toList(); + return item.copyWith(attachments: done); + }).toList(); + emit(state.copyWith(items: items)); + } + Future loadHistory() async { if (state.isLoadingHistory) return; emit(state.copyWith(isLoadingHistory: true)); diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index fcb706a..e3f30ef 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -44,6 +44,7 @@ const _attachmentPreviewGap = 8.0; const _bottomStackReservedHeight = 116.0; const _toolResultWidthFactor = 0.9; const _pullRefreshMinVisibleMs = 450; +const _waitingIndicatorReservedHeight = 42.0; const homeConversationStageKey = ValueKey('home_conversation_stage'); const homeBottomInputStackKey = ValueKey('home_bottom_input_stack'); @@ -64,7 +65,6 @@ const _recordingActiveLabelColor = AppColors.white; class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; - final Future Function(String transcript)? onAutoSendTranscript; final ChatBloc? chatBloc; final bool autoLoadHistory; final List initialSelectedImages; @@ -73,7 +73,6 @@ class HomeScreen extends StatefulWidget { super.key, this.voiceRecorder, this.onTranscribeAudio, - this.onAutoSendTranscript, this.chatBloc, this.autoLoadHistory = true, this.initialSelectedImages = const [], @@ -91,7 +90,6 @@ class _HomeScreenState extends State late final VoiceRecorder _voiceRecorder; late final InboxApi _inboxApi; late final Future Function(String filePath) _transcribeAudio; - late final Future Function(String transcript) _autoSendTranscript; late final AnimationController _listeningAnimationController; bool _isRecording = false; bool _isHoldToSpeakMode = true; @@ -101,6 +99,8 @@ class _HomeScreenState extends State bool _isPullRefreshing = false; int _unreadCount = 0; final List _selectedImages = []; + int _lastObservedItemCount = 0; + bool _lastObservedWaiting = false; @override void initState() { @@ -110,7 +110,6 @@ class _HomeScreenState extends State _inboxApi = sl(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; - _autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage; _listeningAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: _rippleDurationMs), @@ -119,6 +118,8 @@ class _HomeScreenState extends State if (widget.autoLoadHistory) { _chatBloc.loadHistory(); } + _lastObservedItemCount = _chatBloc.state.items.length; + _lastObservedWaiting = _isAgentWaiting(_chatBloc.state); _loadUnreadCount(); } @@ -154,6 +155,15 @@ class _HomeScreenState extends State if (state.error != null) { Toast.show(context, state.error!, type: ToastType.error); } + final isWaitingNow = _isAgentWaiting(state); + final hasItemCountChanged = + state.items.length != _lastObservedItemCount; + final waitingStateChanged = isWaitingNow != _lastObservedWaiting; + if (hasItemCountChanged || waitingStateChanged) { + _scheduleAutoScroll(animated: hasItemCountChanged); + } + _lastObservedItemCount = state.items.length; + _lastObservedWaiting = isWaitingNow; }, builder: (context, state) { return Scaffold( @@ -190,8 +200,7 @@ class _HomeScreenState extends State } Widget _buildChatArea(BuildContext context, ChatState state) { - final showWaitingIndicator = - state.isWaitingFirstToken || state.isStreaming || state.isCancelling; + final showWaitingIndicator = _isAgentWaiting(state); if (state.isLoadingHistory && state.items.isEmpty) { return const Center( @@ -219,7 +228,12 @@ class _HomeScreenState extends State child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: AppSpacing.sm), + padding: EdgeInsets.only( + top: AppSpacing.sm, + bottom: showWaitingIndicator + ? _waitingIndicatorReservedHeight + : AppSpacing.none, + ), itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), itemBuilder: (context, index) { @@ -391,6 +405,28 @@ class _HomeScreenState extends State context.read().loadMoreHistory(); } + bool _isAgentWaiting(ChatState state) { + return state.isWaitingFirstToken || state.isStreaming || state.isCancelling; + } + + void _scheduleAutoScroll({required bool animated}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) { + return; + } + final maxExtent = _scrollController.position.maxScrollExtent; + if (animated) { + _scrollController.animateTo( + maxExtent, + duration: const Duration(milliseconds: _scrollDurationMs), + curve: Curves.easeOut, + ); + return; + } + _scrollController.jumpTo(maxExtent); + }); + } + Widget _buildChatItem(ChatListItem item) { switch (item.type) { case ChatItemType.message: @@ -482,47 +518,89 @@ class _HomeScreenState extends State } bool _isRenderableImageAttachment(Map attachment) { + final path = attachment['path']; final url = attachment['url']; final mimeType = attachment['mimeType']; - return url is String && - url.isNotEmpty && + final hasRenderableSource = + (url is String && url.isNotEmpty) || + (path is String && path.isNotEmpty); + return hasRenderableSource && mimeType is String && mimeType.startsWith('image/'); } Widget _buildHistoryAttachmentTile(Map attachment) { + final path = attachment['path']; final url = attachment['url']; - if (url is! String || url.isEmpty) { + final isUploading = attachment['uploading'] == true; + + final Widget image; + if (url is String && url.isNotEmpty) { + image = Image.network( + url, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center( + child: AppLoadingIndicator( + variant: AppLoadingVariant.inline, + size: _transcribingSpinnerSize, + strokeWidth: _transcribingStrokeWidth, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + LucideIcons.imageOff, + size: _iconSize, + color: AppColors.slate500, + ), + ); + }, + ); + } else if (path is String && path.isNotEmpty) { + image = Image.file( + File(path), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon( + LucideIcons.imageOff, + size: _iconSize, + color: AppColors.slate500, + ), + ); + }, + ); + } else { return const SizedBox.shrink(); } + return ClipRRect( borderRadius: BorderRadius.circular(_attachmentPreviewRadius), child: Container( width: _attachmentPreviewSize, height: _attachmentPreviewSize, color: AppColors.slate100, - child: Image.network( - url, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return const Center( - child: AppLoadingIndicator( - variant: AppLoadingVariant.inline, - size: _transcribingSpinnerSize, - strokeWidth: _transcribingStrokeWidth, + child: Stack( + fit: StackFit.expand, + children: [ + image, + if (isUploading) + ColoredBox( + color: AppColors.slate900.withValues(alpha: 0.2), + child: const Center( + child: AppLoadingIndicator( + variant: AppLoadingVariant.inline, + size: _transcribingSpinnerSize, + strokeWidth: _transcribingStrokeWidth, + color: AppColors.white, + trackColor: AppColors.slate200, + ), + ), ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon( - LucideIcons.imageOff, - size: _iconSize, - color: AppColors.slate500, - ), - ); - }, + ], ), ), ); @@ -841,7 +919,7 @@ class _HomeScreenState extends State }); try { - await context.read().sendMessage(content, images: images); + await _chatBloc.sendMessage(content, images: images); } finally { if (mounted) { setState(() { @@ -1034,8 +1112,11 @@ class _HomeScreenState extends State TextPosition(offset: transcript.length), ); if (autoSendAfterTranscribe) { - _messageController.clear(); - await _autoSendTranscript(normalizedTranscript); + _messageController.text = normalizedTranscript; + _messageController.selection = TextSelection.fromPosition( + TextPosition(offset: normalizedTranscript.length), + ); + await _sendMessage(context); } } catch (error) { if (!mounted) { diff --git a/apps/test/features/chat/data/services/ag_ui_service_test.dart b/apps/test/features/chat/data/services/ag_ui_service_test.dart new file mode 100644 index 0000000..2752ff1 --- /dev/null +++ b/apps/test/features/chat/data/services/ag_ui_service_test.dart @@ -0,0 +1,152 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; +import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; + +class _FakeApiClient implements IApiClient { + _FakeApiClient({required this.sseLines}); + + final List sseLines; + + @override + Future> delete(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> get(String path, {Options? options}) { + throw UnimplementedError(); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) async { + return Stream.fromIterable(sseLines); + } + + @override + Future> patch(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> post(String path, {data, Options? options}) async { + final payload = { + 'taskId': 'task-1', + 'threadId': 'thread-1', + 'runId': 'run-new', + 'created': true, + }; + return Response( + requestOptions: RequestOptions(path: path), + data: payload as T, + statusCode: 202, + ); + } +} + +List _buildSseEvent({ + required String id, + required String type, + required String payload, +}) { + return ['id: $id', 'event: $type', 'data: $payload', '']; +} + +void main() { + test( + 'sendMessage ignores stale run events and waits for expected run', + () async { + final oldRunLines = _buildSseEvent( + id: '1', + type: AgUiEventTypeWire.runStarted, + payload: + '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-old"}', + ); + final oldFinishedLines = _buildSseEvent( + id: '2', + type: AgUiEventTypeWire.runFinished, + payload: + '{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-old"}', + ); + final newRunLines = _buildSseEvent( + id: '3', + type: AgUiEventTypeWire.runStarted, + payload: + '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}', + ); + final newFinishedLines = _buildSseEvent( + id: '4', + type: AgUiEventTypeWire.runFinished, + payload: + '{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}', + ); + + final service = AgUiService( + apiClient: _FakeApiClient( + sseLines: [ + ...oldRunLines, + ...oldFinishedLines, + ...newRunLines, + ...newFinishedLines, + ], + ), + ); + final events = []; + service.onEvent = events.add; + + await service.sendMessage('hello'); + + expect(events, hasLength(2)); + expect(events.first, isA()); + expect((events.first as RunStartedEvent).runId, 'run-new'); + expect(events.last, isA()); + expect((events.last as RunFinishedEvent).runId, 'run-new'); + }, + ); + + test( + 'sendMessage accepts in-run terminal event without runId after binding', + () async { + final newRunLines = _buildSseEvent( + id: '11', + type: AgUiEventTypeWire.runStarted, + payload: + '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}', + ); + final noRunIdTextLines = _buildSseEvent( + id: '12', + type: AgUiEventTypeWire.textMessageEnd, + payload: + '{"type":"TEXT_MESSAGE_END","threadId":"thread-1","messageId":"m1","answer":"ok","role":"assistant","status":"success"}', + ); + final noRunIdFinishedLines = _buildSseEvent( + id: '13', + type: AgUiEventTypeWire.runFinished, + payload: '{"type":"RUN_FINISHED","threadId":"thread-1"}', + ); + + final service = AgUiService( + apiClient: _FakeApiClient( + sseLines: [ + ...newRunLines, + ...noRunIdTextLines, + ...noRunIdFinishedLines, + ], + ), + ); + final events = []; + service.onEvent = events.add; + + await service.sendMessage('hello'); + + expect(events, hasLength(3)); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }, + ); +} diff --git a/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart new file mode 100644 index 0000000..0e6f9b8 --- /dev/null +++ b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; +import 'package:social_app/features/chat/data/models/chat_list_item.dart'; +import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; +import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; + +class _NoopApiClient implements IApiClient { + @override + Future> delete(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> get(String path, {Options? options}) { + throw UnimplementedError(); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) { + throw UnimplementedError(); + } + + @override + Future> patch(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> post(String path, {data, Options? options}) { + throw UnimplementedError(); + } +} + +class _FakeAgUiService extends AgUiService { + _FakeAgUiService() : super(apiClient: _NoopApiClient()); + + Completer? pendingResult; + Object? nextError; + + @override + Future sendMessage( + String content, { + List? images, + }) async { + final error = nextError; + if (error != null) { + nextError = null; + throw error; + } + final pending = pendingResult; + if (pending != null) { + return pending.future; + } + return const SendMessageResult(uploadedAttachments: []); + } + + void emitEvent(AgUiEvent event) { + onEvent(event); + } +} + +void main() { + group('ChatBloc attachment sync', () { + late _FakeAgUiService service; + late ChatBloc bloc; + + setUp(() { + service = _FakeAgUiService(); + bloc = ChatBloc(service: service, apiClient: _NoopApiClient()); + }); + + tearDown(() async { + await bloc.close(); + }); + + test('optimistic local image is replaced with uploaded url', () async { + final completer = Completer(); + service.pendingResult = completer; + + final sendFuture = bloc.sendMessage( + 'hello', + images: [ + XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'), + ], + ); + + await Future.delayed(Duration.zero); + final optimistic = bloc.state.items.last as TextMessageItem; + expect(optimistic.attachments, hasLength(1)); + expect(optimistic.attachments.first['path'], '/tmp/local.jpg'); + expect(optimistic.attachments.first['uploading'], isTrue); + + completer.complete( + const SendMessageResult( + uploadedAttachments: [ + UploadedAttachment( + localPath: '/tmp/local.jpg', + url: 'https://cdn.example.com/a.jpg', + mimeType: 'image/jpeg', + ), + ], + ), + ); + await sendFuture; + + final synced = bloc.state.items.last as TextMessageItem; + expect(synced.attachments.first['url'], 'https://cdn.example.com/a.jpg'); + expect(synced.attachments.first['uploading'], isFalse); + }); + + test( + 'upload failure clears uploading state to avoid endless spinner', + () async { + service.nextError = StateError('upload failed'); + + await bloc.sendMessage( + 'hello', + images: [ + XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'), + ], + ); + + final failed = bloc.state.items.last as TextMessageItem; + expect(failed.attachments.first['uploading'], isFalse); + expect(bloc.state.error, contains('upload failed')); + }, + ); + + test('tool call stays visible until assistant final output', () { + service.emitEvent( + ToolCallStartEvent(toolCallId: 'tool-1', toolCallName: 'ocr_image'), + ); + var toolItem = bloc.state.items.last as ToolCallItem; + expect(toolItem.status, ToolCallStatus.pending); + + service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-1')); + toolItem = bloc.state.items.last as ToolCallItem; + expect(toolItem.status, ToolCallStatus.executing); + + service.emitEvent( + ToolCallResultEvent( + messageId: 'tool-msg-1', + toolCallId: 'tool-1', + toolName: 'ocr_image', + resultSummary: 'done', + status: 'success', + uiSchema: null, + ), + ); + toolItem = bloc.state.items.last as ToolCallItem; + expect(toolItem.status, ToolCallStatus.completed); + + service.emitEvent( + TextMessageEndEvent( + messageId: 'assistant-1', + answer: '识别完成', + role: 'assistant', + status: 'success', + uiSchema: null, + ), + ); + + expect(bloc.state.items.whereType(), isEmpty); + expect(bloc.state.items.whereType().length, 1); + }); + + test('run error keeps tool card and marks it failed', () { + service.emitEvent( + ToolCallStartEvent(toolCallId: 'tool-err', toolCallName: 'ocr_image'), + ); + service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-err')); + + service.emitEvent(RunErrorEvent(message: 'runtime execution failed')); + + final toolItem = bloc.state.items.whereType().single; + expect(toolItem.status, ToolCallStatus.error); + expect(toolItem.errorMessage, '本次运行已失败'); + expect(bloc.state.error, 'runtime execution failed'); + }); + }); +}