From 45532bf78fe8610adcac670c0f58288dd686c2c7 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:42:09 +0530 Subject: [PATCH 1/2] feat(conversation): improve OpenWebUI error handling and parsing --- lib/core/models/chat_message.dart | 76 +++++++++++++++++++ lib/core/services/api_service.dart | 8 ++ lib/core/services/conversation_parsing.dart | 55 ++++++++++++++ lib/core/services/streaming_helper.dart | 37 +++++---- .../widgets/assistant_message_widget.dart | 55 +++++++++++++- 5 files changed, 211 insertions(+), 20 deletions(-) diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index 424678e..dba7f31 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -34,12 +34,88 @@ sealed class ChatMessage with _$ChatMessage { @JsonKey(includeFromJson: false, includeToJson: false) @Default([]) List versions, + // Error information from OpenWebUI (stored separately from content) + @JsonKey(fromJson: _chatMessageErrorFromJson, toJson: _chatMessageErrorToJson) + ChatMessageError? error, }) = _ChatMessage; factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); } +/// Error information for a chat message, matching OpenWebUI's error format. +/// OpenWebUI stores errors as `{ error: { content: "..." } }` on messages. +@freezed +abstract class ChatMessageError with _$ChatMessageError { + const factory ChatMessageError({ + /// The error message content + @JsonKey(fromJson: _nullableString) String? content, + }) = _ChatMessageError; + + factory ChatMessageError.fromJson(Map json) => + _$ChatMessageErrorFromJson(json); +} + +/// Parse ChatMessageError from various OpenWebUI formats. +ChatMessageError? _chatMessageErrorFromJson(dynamic value) { + if (value == null) return null; + + // Legacy format: error === true means content IS the error + if (value == true) { + return const ChatMessageError(content: null); + } + + if (value is String && value.isNotEmpty) { + return ChatMessageError(content: value); + } + + if (value is Map) { + // Most common: { content: "error message" } + final content = value['content']; + if (content is String && content.isNotEmpty) { + return ChatMessageError(content: content); + } + + // Alternative: { message: "error message" } + final message = value['message']; + if (message is String && message.isNotEmpty) { + return ChatMessageError(content: message); + } + + // Nested error: { error: { message: "..." } } + final nestedError = value['error']; + if (nestedError is Map) { + final nestedMessage = nestedError['message']; + if (nestedMessage is String && nestedMessage.isNotEmpty) { + return ChatMessageError(content: nestedMessage); + } + } + + // FastAPI detail format: { detail: "..." } + final detail = value['detail']; + if (detail is String && detail.isNotEmpty) { + return ChatMessageError(content: detail); + } + + // If it's a map but we couldn't extract content, still return an error + // to indicate there was an error (matches legacy error === true behavior) + return const ChatMessageError(content: null); + } + + return null; +} + +/// Convert ChatMessageError to OpenWebUI format for persistence. +Map? _chatMessageErrorToJson(ChatMessageError? error) { + if (error == null) return null; + if (error.content == null) { + // Legacy format - just return true to indicate error + // But OpenWebUI expects a map, so return empty content + return const {'content': ''}; + } + return {'content': error.content}; +} + @freezed abstract class ChatMessageVersion with _$ChatMessageVersion { const factory ChatMessageVersion({ diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index f384032..2a587d4 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -843,6 +843,8 @@ class ApiService { 'attachment_ids': List.from(msg.attachmentIds!), if (_sanitizeFilesForWebUI(msg.files) != null) 'files': _sanitizeFilesForWebUI(msg.files), + // Preserve error field for OpenWebUI compatibility + if (msg.error != null) 'error': msg.error!.toJson(), }; // Update parent's childrenIds if there's a previous message @@ -863,6 +865,8 @@ class ApiService { 'attachment_ids': List.from(msg.attachmentIds!), if (_sanitizeFilesForWebUI(msg.files) != null) 'files': _sanitizeFilesForWebUI(msg.files), + // Preserve error field for OpenWebUI compatibility + if (msg.error != null) 'error': msg.error!.toJson(), }); previousId = messageId; @@ -977,6 +981,8 @@ class ApiService { 'sources': msg.sources.map((s) => s.toJson()).toList(), // Include usage statistics for persistence (issue #274) if (msg.usage != null) 'usage': msg.usage, + // Preserve error field for OpenWebUI compatibility + if (msg.error != null) 'error': msg.error!.toJson(), }; // Update parent's childrenIds @@ -1014,6 +1020,8 @@ class ApiService { 'sources': msg.sources.map((s) => s.toJson()).toList(), // Include usage statistics for persistence (issue #274) if (msg.usage != null) 'usage': msg.usage, + // Preserve error field for OpenWebUI compatibility + if (msg.error != null) 'error': msg.error!.toJson(), }); previousId = messageId; diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index 12b134b..88d0b76 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -191,6 +191,57 @@ List>? _extractToolCalls( return null; } +/// Extract error data from OpenWebUI message format. +/// OpenWebUI stores errors in a separate 'error' field with 'content' inside. +/// Returns a map suitable for ChatMessageError.fromJson(). +Map? _extractErrorData( + Map msgData, + Map? historyMsg, +) { + // Check msgData first, then historyMsg + final errorRaw = msgData['error'] ?? historyMsg?['error']; + if (errorRaw == null) return null; + + // Handle different error formats from OpenWebUI + if (errorRaw is Map) { + // Most common: { error: { content: "error message" } } + final content = errorRaw['content']; + if (content is String && content.isNotEmpty) { + return {'content': content}; + } + // Alternative: { error: { message: "error message" } } + final message = errorRaw['message']; + if (message is String && message.isNotEmpty) { + return {'content': message}; + } + // Nested error: { error: { error: { message: "..." } } } + final nestedError = errorRaw['error']; + if (nestedError is Map) { + final nestedMessage = nestedError['message']; + if (nestedMessage is String && nestedMessage.isNotEmpty) { + return {'content': nestedMessage}; + } + } + // FastAPI detail format: { detail: "..." } + final detail = errorRaw['detail']; + if (detail is String && detail.isNotEmpty) { + return {'content': detail}; + } + // If it's a map but we couldn't extract content, still return an error + // to indicate there was an error (matches legacy error === true behavior) + return const {'content': null}; + } else if (errorRaw is String && errorRaw.isNotEmpty) { + // Simple string error + return {'content': errorRaw}; + } else if (errorRaw == true) { + // Legacy format: error === true means content IS the error message + // Return a marker so the UI knows this is an error message + return const {'content': null}; + } + + return null; +} + Map _parseOpenWebUIMessageToJson( Map msgData, { Map? historyMsg, @@ -253,6 +304,9 @@ Map _parseOpenWebUIMessageToJson( } } + // Extract error field from OpenWebUI - preserve it separately for round-trip + final errorData = _extractErrorData(msgData, historyMsg); + final role = _resolveRole(msgData); final effectiveFiles = msgData['files'] ?? historyMsg?['files']; @@ -325,6 +379,7 @@ Map _parseOpenWebUIMessageToJson( 'sources': _parseSourcesField(sourcesRaw), 'usage': usage, 'versions': const >[], + if (errorData != null) 'error': errorData, }; } diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index c8f16fd..827b9fa 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -1126,34 +1126,33 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ // Server reports an error for the current assistant message try { dynamic err = payload is Map ? payload['error'] : null; - String content = ''; + String errorContent = ''; if (err is Map) { final c = err['content']; if (c is String) { - content = c; + errorContent = c; } else if (c != null) { - content = c.toString(); + errorContent = c.toString(); } } else if (err is String) { - content = err; + errorContent = err; } else if (payload is Map && payload['message'] is String) { - content = payload['message']; - } - if (content.isNotEmpty) { - // Replace current assistant message with a readable error - replaceLastMessageContent('⚠️ $content'); + errorContent = payload['message']; } + // Set the error field on the message for proper OpenWebUI round-trip + // Also drop search-only status rows so the error feels cleaner + updateLastMessageWith((message) { + final filtered = message.statusHistory + .where((status) => status.action != 'knowledge_search') + .toList(growable: false); + return message.copyWith( + error: errorContent.isNotEmpty + ? ChatMessageError(content: errorContent) + : const ChatMessageError(content: null), + statusHistory: filtered, + ); + }); } catch (_) {} - // Drop search-only status rows so the error feels cleaner - updateLastMessageWith((message) { - final filtered = message.statusHistory - .where((status) => status.action != 'knowledge_search') - .toList(growable: false); - if (filtered.length == message.statusHistory.length) { - return message; - } - return message.copyWith(statusHistory: filtered); - }); // Ensure UI exits streaming state wrappedFinishStreaming(); } else if ((type == 'chat:message:delta' || type == 'message') && diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 2bf762e..0afe482 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -878,6 +878,15 @@ class _AssistantMessageWidgetState extends ConsumerState ), ), + // Display error banner if message has an error + if (widget.message is ChatMessage && + (widget.message as ChatMessage).error != null) ...[ + const SizedBox(height: Spacing.sm), + _buildErrorBanner( + (widget.message as ChatMessage).error!, + ), + ], + if (hasCodeExecutions) ...[ const SizedBox(height: Spacing.md), CodeExecutionListView( @@ -924,6 +933,47 @@ class _AssistantMessageWidgetState extends ConsumerState ); } + /// Build an error banner matching OpenWebUI's error display style. + /// Shows error content in a red-tinted container with an info icon. + Widget _buildErrorBanner(ChatMessageError error) { + final theme = Theme.of(context); + final errorColor = theme.colorScheme.error; + final errorContent = error.content; + + // If no content, show a generic error message + final displayText = (errorContent != null && errorContent.isNotEmpty) + ? errorContent + : 'An error occurred while generating this response.'; + + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: errorColor.withValues(alpha: 0.1), + border: Border.all(color: errorColor.withValues(alpha: 0.2)), + borderRadius: BorderRadius.circular(Spacing.sm), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: errorColor, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + displayText, + style: theme.textTheme.bodyMedium?.copyWith( + color: errorColor, + ), + ), + ), + ], + ), + ); + } + Widget _buildEnhancedMarkdownContent(String content) { if (content.trim().isEmpty) { return const SizedBox.shrink(); @@ -1252,7 +1302,10 @@ class _AssistantMessageWidgetState extends ConsumerState final ttsState = ref.watch(textToSpeechControllerProvider); final messageId = _messageId; final hasSpeechText = _ttsPlainText.trim().isNotEmpty; - final isErrorMessage = + // Check for error using the error field (preferred) or legacy content detection + final hasErrorField = widget.message is ChatMessage && + (widget.message as ChatMessage).error != null; + final isErrorMessage = hasErrorField || widget.message.content.contains('⚠️') || widget.message.content.contains('Error') || widget.message.content.contains('timeout') || From 9018e382f7920f3697c041748df37c87b9fb6354 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:55:26 +0530 Subject: [PATCH 2/2] feat(chat): Improve error handling and message versioning --- lib/core/models/chat_message.dart | 35 +++- lib/core/services/api_service.dart | 4 +- lib/core/services/conversation_parsing.dart | 176 +++++++++++++++++- .../chat/providers/chat_providers.dart | 45 +++-- .../widgets/assistant_message_widget.dart | 29 ++- 5 files changed, 257 insertions(+), 32 deletions(-) diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index dba7f31..63d22e3 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -31,7 +31,8 @@ sealed class ChatMessage with _$ChatMessage { List sources, Map? usage, // Previous generated versions of this assistant message (OpenWebUI-style) - @JsonKey(includeFromJson: false, includeToJson: false) + // Parsed from sibling messages in OpenWebUI history + @JsonKey(fromJson: _versionsFromJson, toJson: _versionsToJson) @Default([]) List versions, // Error information from OpenWebUI (stored separately from content) @@ -134,6 +135,9 @@ abstract class ChatMessageVersion with _$ChatMessageVersion { @Default([]) List followUps, @Default([]) List codeExecutions, Map? usage, + // Error information preserved from the original message + @JsonKey(fromJson: _chatMessageErrorFromJson, toJson: _chatMessageErrorToJson) + ChatMessageError? error, }) = _ChatMessageVersion; factory ChatMessageVersion.fromJson(Map json) => @@ -281,6 +285,35 @@ int? _safeInt(dynamic value) { List _stringListToJson(List value) => List.from(value, growable: false); +/// Parse ChatMessageVersion list from JSON. +List _versionsFromJson(dynamic value) { + if (value is List) { + return value + .whereType() + .map((item) { + try { + final Map versionMap = {}; + item.forEach((key, v) { + versionMap[key.toString()] = v; + }); + return ChatMessageVersion.fromJson(versionMap); + } catch (e) { + // Skip invalid entries + return null; + } + }) + .where((item) => item != null) + .cast() + .toList(growable: false); + } + return const []; +} + +/// Convert ChatMessageVersion list to JSON. +List> _versionsToJson(List versions) { + return versions.map((v) => v.toJson()).toList(growable: false); +} + List _statusItemsFromJson(dynamic value) { if (value is List) { return value diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 2a587d4..274f5f7 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1048,7 +1048,7 @@ class ApiService { 'modelIdx': 0, 'done': true, if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files), - // Mirror follow-ups, code executions, and sources for versions + // Mirror follow-ups, code executions, sources, and errors for versions if (ver.followUps.isNotEmpty) 'followUps': List.from(ver.followUps), if (ver.codeExecutions.isNotEmpty) @@ -1057,6 +1057,8 @@ class ApiService { .toList(), if (ver.sources.isNotEmpty) 'sources': ver.sources.map((s) => s.toJson()).toList(), + // Preserve error field for OpenWebUI compatibility + if (ver.error != null) 'error': ver.error!.toJson(), }; // Link into parent (parentForVersions is always non-null here) if (messagesMap.containsKey(parentForVersions)) { diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index 88d0b76..64fb7a7 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -146,16 +146,24 @@ Map parseFullConversation(Map chatData) { merged['content'] = synthesized; } - messages.add( - _parseOpenWebUIMessageToJson(merged, historyMsg: historyMsg), + final parsed = _parseOpenWebUIMessageToJson( + merged, + historyMsg: historyMsg, ); + // Add versions from siblings + _addVersionsFromSiblings(parsed, msgData, historyMessagesMap); + messages.add(parsed); index = j; continue; } - messages.add( - _parseOpenWebUIMessageToJson(msgData, historyMsg: historyMsg), + final parsed = _parseOpenWebUIMessageToJson( + msgData, + historyMsg: historyMsg, ); + // Add versions from siblings + _addVersionsFromSiblings(parsed, msgData, historyMessagesMap); + messages.add(parsed); index++; } } @@ -191,6 +199,120 @@ List>? _extractToolCalls( return null; } +/// Add versions from sibling messages (alternative responses with same parent). +/// Siblings are stored in `_siblings` by `_buildMessagesListFromHistory`. +void _addVersionsFromSiblings( + Map parsed, + Map msgData, + Map? historyMessagesMap, +) { + final siblings = msgData['_siblings']; + if (siblings is! List || siblings.isEmpty) return; + + final versions = >[]; + for (final siblingData in siblings) { + if (siblingData is! Map) continue; + + final siblingId = siblingData['id']?.toString(); + final historyMsg = historyMessagesMap != null && siblingId != null + ? (historyMessagesMap[siblingId] as Map?) + : null; + + // Parse the sibling as a version + final version = _parseSiblingAsVersion(siblingData, historyMsg: historyMsg); + if (version != null) { + versions.add(version); + } + } + + if (versions.isNotEmpty) { + parsed['versions'] = versions; + } +} + +/// Parse a sibling message as a ChatMessageVersion JSON map. +Map? _parseSiblingAsVersion( + Map msgData, { + Map? historyMsg, +}) { + // Extract content (same logic as _parseOpenWebUIMessageToJson) + dynamic content = msgData['content']; + if ((content == null || (content is String && content.isEmpty)) && + historyMsg != null && + historyMsg['content'] != null) { + content = historyMsg['content']; + } + + var contentString = ''; + if (content is List) { + final buffer = StringBuffer(); + for (final entry in content) { + if (entry is Map && entry['type'] == 'text') { + final text = entry['text']?.toString(); + if (text != null && text.isNotEmpty) { + buffer.write(text); + } + } + } + contentString = buffer.toString(); + } else { + contentString = content?.toString() ?? ''; + } + + if (historyMsg != null) { + final histContent = historyMsg['content']; + if (histContent is String && histContent.length > contentString.length) { + contentString = histContent; + } + } + + // Extract files + final effectiveFiles = msgData['files'] ?? historyMsg?['files']; + List>? files; + if (effectiveFiles is List) { + final allFiles = >[]; + for (final entry in effectiveFiles) { + if (entry is! Map) continue; + if (entry['type'] != null && entry['url'] != null) { + final fileMap = { + 'type': entry['type'], + 'url': entry['url'], + }; + if (entry['name'] != null) fileMap['name'] = entry['name']; + if (entry['size'] != null) fileMap['size'] = entry['size']; + allFiles.add(fileMap); + } + } + files = allFiles.isNotEmpty ? allFiles : null; + } + + // Extract other fields + final sourcesRaw = historyMsg != null + ? historyMsg['sources'] ?? historyMsg['citations'] + : msgData['sources'] ?? msgData['citations']; + final followUpsRaw = historyMsg != null + ? historyMsg['followUps'] ?? historyMsg['follow_ups'] + : msgData['followUps'] ?? msgData['follow_ups']; + final codeExecRaw = historyMsg != null + ? historyMsg['codeExecutions'] ?? historyMsg['code_executions'] + : msgData['codeExecutions'] ?? msgData['code_executions']; + final rawUsage = _coerceJsonMap(historyMsg?['usage'] ?? msgData['usage']); + final errorData = _extractErrorData(msgData, historyMsg); + + return { + 'id': (msgData['id'] ?? _uuid.v4()).toString(), + 'content': contentString, + 'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(), + if (msgData['model'] != null) 'model': msgData['model'].toString(), + if (files != null) 'files': files, + 'sources': _parseSourcesField(sourcesRaw), + 'followUps': _coerceStringList(followUpsRaw), + 'codeExecutions': _parseCodeExecutionsField(codeExecRaw), + if (rawUsage.isNotEmpty) 'usage': rawUsage, + if (errorData != null) 'error': errorData, + }; +} + /// Extract error data from OpenWebUI message format. /// OpenWebUI stores errors in a separate 'error' field with 'content' inside. /// Returns a map suitable for ChatMessageError.fromJson(). @@ -393,6 +515,8 @@ String _resolveRole(Map msgData) { return 'user'; } +/// Build the message chain from history, following parent links from currentId. +/// Also collects sibling messages (alternative versions) for each message. List> _buildMessagesListFromHistory( Map history, ) { @@ -402,6 +526,7 @@ List> _buildMessagesListFromHistory( return const []; } + // Build the main chain from currentId back to root List> buildChain(String? id) { if (id == null) return const []; final raw = messagesMap[id]; @@ -415,7 +540,48 @@ List> _buildMessagesListFromHistory( return [msg]; } - return buildChain(currentId); + final chain = buildChain(currentId); + + // For each message in the chain, find sibling versions + // Siblings are other children of the same parent + for (final msg in chain) { + final parentId = msg['parentId']?.toString(); + if (parentId == null || parentId.isEmpty) continue; + + final parent = messagesMap[parentId]; + if (parent is! Map) continue; + + final childrenIds = parent['childrenIds']; + if (childrenIds is! List || childrenIds.length <= 1) continue; + + // Collect sibling messages (same role, different id) + final msgId = msg['id']?.toString(); + final msgRole = msg['role']?.toString(); + final siblings = >[]; + + for (final siblingId in childrenIds) { + final sibId = siblingId?.toString(); + if (sibId == null || sibId == msgId) continue; + + final siblingRaw = messagesMap[sibId]; + if (siblingRaw is! Map) continue; + + final sibling = _coerceJsonMap(siblingRaw); + final siblingRole = sibling['role']?.toString(); + + // Only include siblings with the same role (e.g., alternative assistant responses) + if (siblingRole == msgRole) { + sibling['id'] = sibId; + siblings.add(sibling); + } + } + + if (siblings.isNotEmpty) { + msg['_siblings'] = siblings; + } + } + + return chain; } DateTime _parseTimestamp(dynamic timestamp) { diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 99918cf..9a14e5f 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -644,6 +644,7 @@ class ChatMessagesNotifier extends Notifier> { followUps: List.from(last.followUps), codeExecutions: List.from(last.codeExecutions), usage: last.usage == null ? null : Map.from(last.usage!), + error: last.error, // Preserve error in version snapshot ); final updated = last.copyWith( @@ -655,6 +656,7 @@ class ChatMessagesNotifier extends Notifier> { codeExecutions: const [], sources: const [], usage: null, + error: null, // Clear error for new generation versions: [...last.versions, snapshot], ); @@ -1414,6 +1416,7 @@ Future regenerateMessage( followUps: prev.followUps, codeExecutions: prev.codeExecutions, usage: prev.usage, + error: prev.error, // Preserve error in version snapshot ); ref .read(chatMessagesProvider.notifier) @@ -2483,16 +2486,16 @@ Future _sendMessageInternal( final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '''⚠️ There was an issue with the message format. This might be because: - -• The image attachment couldn't be processed -• The request format is incompatible with the selected model -• The message contains unsupported content - -Please try sending the message again, or try without attachments.''', + content: '', timestamp: DateTime.now(), isStreaming: false, + error: const ChatMessageError( + content: 'There was an issue with the message format. This might be ' + 'because the image attachment couldn\'t be processed, the request ' + 'format is incompatible with the selected model, or the message ' + 'contains unsupported content. Please try sending the message ' + 'again, or try without attachments.', + ), ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else if (e.toString().contains('401') || e.toString().contains('403')) { @@ -2502,11 +2505,14 @@ Please try sending the message again, or try without attachments.''', final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '⚠️ Unable to connect to the AI model. The server returned an error (500).\n\n' - 'This is typically a server-side issue. Please try again or contact your administrator.', + content: '', timestamp: DateTime.now(), isStreaming: false, + error: const ChatMessageError( + content: 'Unable to connect to the AI model. The server returned an ' + 'error (500). This is typically a server-side issue. Please try ' + 'again or contact your administrator.', + ), ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else if (e.toString().contains('404')) { @@ -2517,11 +2523,14 @@ Please try sending the message again, or try without attachments.''', final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '🤖 The selected AI model doesn\'t seem to be available.\n\n' - 'Please try selecting a different model or check with your administrator.', + content: '', timestamp: DateTime.now(), isStreaming: false, + error: const ChatMessageError( + content: 'The selected AI model doesn\'t seem to be available. ' + 'Please try selecting a different model or check with your ' + 'administrator.', + ), ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else { @@ -2529,11 +2538,13 @@ Please try sending the message again, or try without attachments.''', final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '❌ An unexpected error occurred while processing your request.\n\n' - 'Please try again or check your connection.', + content: '', timestamp: DateTime.now(), isStreaming: false, + error: const ChatMessageError( + content: 'An unexpected error occurred while processing your request. ' + 'Please try again or check your connection.', + ), ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 0afe482..b9bc418 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -878,13 +878,10 @@ class _AssistantMessageWidgetState extends ConsumerState ), ), - // Display error banner if message has an error - if (widget.message is ChatMessage && - (widget.message as ChatMessage).error != null) ...[ + // Display error banner if message or active version has an error + if (_getActiveError() != null) ...[ const SizedBox(height: Spacing.sm), - _buildErrorBanner( - (widget.message as ChatMessage).error!, - ), + _buildErrorBanner(_getActiveError()!), ], if (hasCodeExecutions) ...[ @@ -933,6 +930,21 @@ class _AssistantMessageWidgetState extends ConsumerState ); } + /// Get the error for the currently active message or version. + ChatMessageError? _getActiveError() { + if (widget.message is! ChatMessage) return null; + final msg = widget.message as ChatMessage; + + // If viewing a version, return the version's error + if (_activeVersionIndex >= 0 && + _activeVersionIndex < msg.versions.length) { + return msg.versions[_activeVersionIndex].error; + } + + // Otherwise return the main message's error + return msg.error; + } + /// Build an error banner matching OpenWebUI's error display style. /// Shows error content in a red-tinted container with an info icon. Widget _buildErrorBanner(ChatMessageError error) { @@ -1303,8 +1315,9 @@ class _AssistantMessageWidgetState extends ConsumerState final messageId = _messageId; final hasSpeechText = _ttsPlainText.trim().isNotEmpty; // Check for error using the error field (preferred) or legacy content detection - final hasErrorField = widget.message is ChatMessage && - (widget.message as ChatMessage).error != null; + // Also check the active version's error if viewing a version + final activeError = _getActiveError(); + final hasErrorField = activeError != null; final isErrorMessage = hasErrorField || widget.message.content.contains('⚠️') || widget.message.content.contains('Error') ||