Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lib/core/screens/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -295,7 +296,7 @@ class _ChatScreenState extends State<ChatScreen> {
(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) {
Expand Down Expand Up @@ -687,7 +688,7 @@ class _ChatScreenState extends State<ChatScreen> {
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) {
Expand Down Expand Up @@ -719,7 +720,7 @@ class _ChatScreenState extends State<ChatScreen> {

final msg = item as Map<String, dynamic>;
final role = (msg['role'] as String?) ?? 'assistant';
final content = (msg['content'] as String?) ?? '';
final content = messageContentToText(msg['content']);
final isUser = role == 'user';

return _MessageBubble(
Expand Down
37 changes: 37 additions & 0 deletions lib/core/utils/message_content.dart
Original file line number Diff line number Diff line change
@@ -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]';
}
43 changes: 43 additions & 0 deletions test/message_content_test.dart
Original file line number Diff line number Diff line change
@@ -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]',
);
});
});
}
Loading