diff --git a/lib/core/screens/chat_screen.dart b/lib/core/screens/chat_screen.dart index a1770f0..bdc3078 100644 --- a/lib/core/screens/chat_screen.dart +++ b/lib/core/screens/chat_screen.dart @@ -10,6 +10,7 @@ import 'package:speech_to_text/speech_recognition_result.dart'; import 'package:speech_to_text/speech_to_text.dart'; import '../services/connection_manager.dart'; +import '../utils/message_content.dart'; import '../utils/responsive.dart'; class ChatScreen extends StatefulWidget { @@ -295,7 +296,7 @@ class _ChatScreenState extends State { (msg['toolCallName'] as String?) ?? ''; final toolCallId = (msg['tool_call_id'] as String?) ?? ''; - final content = (msg['content'] as String?) ?? ''; + final content = messageContentToText(msg['content']); String toolName = name.isNotEmpty ? name : ''; if (toolName.isEmpty && content.isNotEmpty) { @@ -687,7 +688,7 @@ class _ChatScreenState extends State { continue; } if (role != 'user' && role != 'assistant') continue; - final content = (msg['content'] as String?) ?? ''; + final content = messageContentToText(msg['content']); if (content.isEmpty) continue; if (currentGroup.isNotEmpty) { @@ -719,7 +720,7 @@ class _ChatScreenState extends State { final msg = item as Map; final role = (msg['role'] as String?) ?? 'assistant'; - final content = (msg['content'] as String?) ?? ''; + final content = messageContentToText(msg['content']); final isUser = role == 'user'; return _MessageBubble( diff --git a/lib/core/utils/message_content.dart b/lib/core/utils/message_content.dart new file mode 100644 index 0000000..5986579 --- /dev/null +++ b/lib/core/utils/message_content.dart @@ -0,0 +1,37 @@ +/// Convert Hermes/OpenAI message content into displayable text. +/// +/// Legacy messages carry a String. Multimodal messages carry a list of typed +/// parts such as `{type: text, text: ...}` and `{type: image_url, ...}`. +/// Keep rendering resilient when the gateway adds new part types. +String messageContentToText(dynamic content) { + if (content == null) return ''; + if (content is String) return content; + + if (content is List) { + return content + .map(_contentPartToText) + .where((part) => part.isNotEmpty) + .join('\n\n'); + } + + return _contentPartToText(content); +} + +String _contentPartToText(dynamic part) { + if (part == null) return ''; + if (part is String) return part; + if (part is! Map) return part.toString(); + + final text = part['text']; + if (text is String && text.isNotEmpty) return text; + + final type = part['type']?.toString() ?? 'unknown'; + if (type.contains('image') || part.containsKey('image_url')) { + return '[Image]'; + } + if (type.contains('file') || part.containsKey('file')) { + return '[File]'; + } + + return '[Unsupported content: $type]'; +} diff --git a/test/message_content_test.dart b/test/message_content_test.dart new file mode 100644 index 0000000..8acc7b4 --- /dev/null +++ b/test/message_content_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hermes_android/core/utils/message_content.dart'; + +void main() { + group('messageContentToText', () { + test('keeps legacy string content unchanged', () { + expect(messageContentToText('hello'), 'hello'); + }); + + test('joins OpenAI text content parts', () { + expect( + messageContentToText([ + {'type': 'text', 'text': 'first'}, + {'type': 'text', 'text': 'second'}, + ]), + 'first\n\nsecond', + ); + }); + + test('renders mixed text and image content without throwing', () { + expect( + messageContentToText([ + {'type': 'text', 'text': 'caption'}, + { + 'type': 'image_url', + 'image_url': {'url': 'https://example.invalid/image.png'}, + }, + ]), + 'caption\n\n[Image]', + ); + }); + + test('handles null and unknown structured content safely', () { + expect(messageContentToText(null), ''); + expect( + messageContentToText([ + {'type': 'custom_part', 'payload': 42}, + ]), + '[Unsupported content: custom_part]', + ); + }); + }); +}