From 2b44ae3e5ef43c086120833b5f28b6f7764ffa8f Mon Sep 17 00:00:00 2001 From: cogwheel <172976095+cogwheel0@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:26:43 +0530 Subject: [PATCH] feat(image): Improve image attachment loading and error handling --- lib/core/services/conversation_parsing.dart | 18 ++- .../chat/providers/chat_providers.dart | 9 +- lib/features/chat/utils/file_utils.dart | 22 +++ .../widgets/assistant_message_widget.dart | 23 ++- .../widgets/enhanced_image_attachment.dart | 145 ++++++++++++++---- .../chat/widgets/user_message_bubble.dart | 19 ++- 6 files changed, 185 insertions(+), 51 deletions(-) create mode 100644 lib/features/chat/utils/file_utils.dart diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index 64fb7a7..414afc6 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -280,6 +280,9 @@ Map? _parseSiblingAsVersion( }; if (entry['name'] != null) fileMap['name'] = entry['name']; if (entry['size'] != null) fileMap['size'] = entry['size']; + if (entry['content_type'] != null) { + fileMap['content_type'] = entry['content_type']; + } allFiles.add(fileMap); } } @@ -448,6 +451,9 @@ Map _parseOpenWebUIMessageToJson( }; if (entry['name'] != null) fileMap['name'] = entry['name']; if (entry['size'] != null) fileMap['size'] = entry['size']; + if (entry['content_type'] != null) { + fileMap['content_type'] = entry['content_type']; + } final headers = _coerceStringMap(entry['headers']); if (headers != null && headers.isNotEmpty) { fileMap['headers'] = headers; @@ -455,12 +461,22 @@ Map _parseOpenWebUIMessageToJson( allFiles.add(fileMap); final url = entry['url'].toString(); - // Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content + // Handle all URL formats: + // 1. /api/v1/files/{id} and /api/v1/files/{id}/content (old format) + // 2. Just a file ID like "abc-123-def" (new OpenWebUI format) final match = RegExp( r'/api/v1/files/([^/]+)(?:/content)?$', ).firstMatch(url); if (match != null) { attachments.add(match.group(1)!); + } else if (!url.startsWith('data:') && + !url.startsWith('http') && + !url.startsWith('/')) { + // New format: URL is just a bare file ID (UUID-like) + // Validate it looks like a reasonable ID (not an empty string) + if (url.isNotEmpty) { + attachments.add(url); + } } } } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 86d7c5d..bd768b1 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1092,7 +1092,8 @@ Future> _buildMessagePayloadWithAttachments({ allFiles.add({ 'type': 'file', 'id': attachmentId, - 'url': '/api/v1/files/$attachmentId', + // OpenWebUI now stores just the file ID, not the full URL path + 'url': attachmentId, 'name': fileName, if (fileSize != null) 'size': fileSize, }); @@ -1801,7 +1802,9 @@ Future _sendMessageInternal( 'type': isImage ? 'image' : 'file', 'id': fileId, 'name': fileName, - 'url': '/api/v1/files/$fileId', // Full URL for conversation parsing compatibility + // OpenWebUI now stores just the file ID, not the full URL path + // The frontend resolves it when displaying + 'url': fileId, if (fileSize != null) 'size': fileSize, if (collectionName != null) 'collection_name': collectionName, if (contentType.isNotEmpty) 'content_type': contentType, @@ -1811,7 +1814,7 @@ Future _sendMessageInternal( 'type': 'file', 'id': fileId, 'name': 'file', - 'url': '/api/v1/files/$fileId', + 'url': fileId, }; } }); diff --git a/lib/features/chat/utils/file_utils.dart b/lib/features/chat/utils/file_utils.dart new file mode 100644 index 0000000..0e32cbb --- /dev/null +++ b/lib/features/chat/utils/file_utils.dart @@ -0,0 +1,22 @@ +// Utility functions for handling file data in chat messages. +// Used by both user and assistant message widgets. + +/// Checks if a file map represents an image. +/// Matches OpenWebUI behavior: type === 'image' OR content_type starts with 'image/' +bool isImageFile(dynamic file) { + if (file is! Map) return false; + if (file['type'] == 'image') return true; + final contentType = file['content_type']?.toString() ?? ''; + return contentType.startsWith('image/'); +} + +/// Extracts the file URL or ID from a file map. +/// OpenWebUI stores either a full URL, data URL, or just the file ID. +/// +/// Returns the URL/ID string, or null if the file has no valid URL. +String? getFileUrl(dynamic file) { + if (file is! Map) return null; + final url = file['url']; + if (url == null) return null; + return url.toString(); +} diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 9952200..3f0f6cd 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -27,6 +27,7 @@ import 'sources/openwebui_sources.dart'; import '../providers/assistant_response_builder_provider.dart'; import '../../../core/services/worker_manager.dart'; import 'streaming_status_widget.dart'; +import '../utils/file_utils.dart'; // Pre-compiled regex patterns for image processing (performance optimization) final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); @@ -1119,12 +1120,9 @@ class _AssistantMessageWidgetState extends ConsumerState final allFiles = filesArray; // Separate images and non-image files - final imageFiles = allFiles - .where((file) => file['type'] == 'image') - .toList(); - final nonImageFiles = allFiles - .where((file) => file['type'] != 'image') - .toList(); + // Match OpenWebUI: type === 'image' OR content_type starts with 'image/' + final imageFiles = allFiles.where(isImageFile).toList(); + final nonImageFiles = allFiles.where((file) => !isImageFile(file)).toList(); final widgets = []; @@ -1164,7 +1162,7 @@ class _AssistantMessageWidgetState extends ConsumerState key: ValueKey('file_single_${imageFiles[0]['url']}'), child: Builder( builder: (context) { - final imageUrl = imageFiles[0]['url'] as String?; + final imageUrl = getFileUrl(imageFiles[0]); if (imageUrl == null) return const SizedBox.shrink(); return EnhancedImageAttachment( @@ -1189,7 +1187,7 @@ class _AssistantMessageWidgetState extends ConsumerState spacing: Spacing.sm, runSpacing: Spacing.sm, children: imageFiles.map((file) { - final imageUrl = file['url'] as String?; + final imageUrl = getFileUrl(file); if (imageUrl == null) return const SizedBox.shrink(); return EnhancedImageAttachment( @@ -1232,12 +1230,13 @@ class _AssistantMessageWidgetState extends ConsumerState spacing: Spacing.sm, runSpacing: Spacing.sm, children: nonImageFiles.map((file) { - final fileUrl = file['url'] as String?; - + final fileUrl = getFileUrl(file); if (fileUrl == null) return const SizedBox.shrink(); - // Extract file ID from URL - handle both formats: - // /api/v1/files/{id} and /api/v1/files/{id}/content + // Extract file ID from URL - handle formats: + // - Bare file ID (new OpenWebUI format): "abc-123-def" + // - /api/v1/files/{id} (legacy format) + // - /api/v1/files/{id}/content (legacy format) String attachmentId = fileUrl; if (fileUrl.contains('/api/v1/files/')) { final fileIdMatch = _fileIdPattern.firstMatch(fileUrl); diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart index e25aee6..776362e 100644 --- a/lib/features/chat/widgets/enhanced_image_attachment.dart +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -30,6 +30,8 @@ final _base64WhitespacePattern = RegExp(r'\s'); /// Call this with the server file ID and image bytes after successful upload. void preCacheImageBytes(String fileId, Uint8List bytes) { if (fileId.isEmpty || bytes.isEmpty) return; + // Clear any previous error state for this file + _globalErrorStates.remove(fileId); _globalImageBytesCache[fileId] = bytes; _globalLoadingStates[fileId] = false; // Detect SVG @@ -132,8 +134,8 @@ class _EnhancedImageAttachmentState String? _errorMessage; bool _isDecoding = false; bool _isSvg = false; - late final String _heroTag; - // Removed unused animation and state flags + late String _heroTag; + bool _hasAttemptedLoad = false; @override bool get wantKeepAlive => true; @@ -150,12 +152,40 @@ class _EnhancedImageAttachmentState }); } + @override + void didUpdateWidget(covariant EnhancedImageAttachment oldWidget) { + super.didUpdateWidget(oldWidget); + // If the attachment ID changed, reload the image + if (oldWidget.attachmentId != widget.attachmentId) { + _heroTag = 'image_${widget.attachmentId}_${identityHashCode(this)}'; + // Reset local state with setState for immediate visual feedback + setState(() { + _cachedImageData = null; + _cachedBytes = null; + _hasAttemptedLoad = false; + _isLoading = true; + _errorMessage = null; + _isDecoding = false; + _isSvg = false; + }); + // Load the new image + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadImage(); + }); + } + } + @override void dispose() { super.dispose(); } Future _loadImage() async { + // Prevent duplicate loads + if (_hasAttemptedLoad) return; + _hasAttemptedLoad = true; + final l10n = AppLocalizations.of(context)!; // Check bytes cache first (populated during upload for instant display) @@ -172,6 +202,8 @@ class _EnhancedImageAttachmentState return; } + // Check for cached errors - if image previously failed, show error immediately + // Note: preCacheImageBytes() clears errors when upload completes successfully final cachedError = _globalErrorStates[widget.attachmentId]; if (cachedError != null) { if (mounted) { @@ -407,8 +439,16 @@ class _EnhancedImageAttachmentState return _buildErrorState(); } - if (_cachedImageData == null) { - return const SizedBox.shrink(); + if (_cachedImageData == null && _cachedBytes == null) { + // No data available - this shouldn't happen in normal flow since + // _loadImage always sets either data, bytes, or error before completing. + // Show error state rather than attempting reload from build(). + return _buildErrorState(); + } + + // If we have bytes but no cached data string, use bytes directly + if (_cachedImageData == null && _cachedBytes != null) { + return _isSvg ? _buildBase64Svg() : _buildBase64Image(); } // Handle different image data formats @@ -692,11 +732,15 @@ class _EnhancedImageAttachmentState } void _showFullScreenImage(BuildContext context) { + // Handle both data URL string and raw bytes cases + if (_cachedImageData == null && _cachedBytes == null) return; + Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (context) => FullScreenImageViewer( - imageData: _cachedImageData!, + imageData: _cachedImageData, + imageBytes: _cachedBytes, tag: _heroTag, isSvg: _isSvg, customHeaders: widget.httpHeaders, @@ -707,31 +751,56 @@ class _EnhancedImageAttachmentState } class FullScreenImageViewer extends ConsumerWidget { - final String imageData; + /// Image data as a URL (http://) or data URL (data:image/...) or base64 string. + /// Either this or [imageBytes] must be provided. + final String? imageData; + + /// Raw image bytes. Used when [imageData] is null. + final Uint8List? imageBytes; + final String tag; final bool isSvg; final Map? customHeaders; const FullScreenImageViewer({ super.key, - required this.imageData, + this.imageData, + this.imageBytes, required this.tag, this.isSvg = false, this.customHeaders, - }); + }) : assert(imageData != null || imageBytes != null, + 'Either imageData or imageBytes must be provided'); @override Widget build(BuildContext context, WidgetRef ref) { Widget imageWidget; - if (imageData.startsWith('http')) { + // If we have raw bytes, use them directly + if (imageData == null && imageBytes != null) { + if (isSvg || _isSvgBytes(imageBytes!)) { + imageWidget = SvgPicture.memory( + imageBytes!, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), + ), + ); + } else { + imageWidget = Image.memory(imageBytes!, fit: BoxFit.contain); + } + } else if (imageData != null && imageData!.startsWith('http')) { // Get authentication headers if available final defaultHeaders = buildImageHeadersFromWidgetRef(ref); final headers = _mergeHeaders(defaultHeaders, customHeaders); - if (isSvg || _isSvgUrl(imageData)) { + if (isSvg || _isSvgUrl(imageData!)) { imageWidget = SvgPicture.network( - imageData, + imageData!, fit: BoxFit.contain, headers: headers, placeholderBuilder: (context) => Center( @@ -750,7 +819,7 @@ class FullScreenImageViewer extends ConsumerWidget { } else { final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); imageWidget = CachedNetworkImage( - imageUrl: imageData, + imageUrl: imageData!, fit: BoxFit.contain, cacheManager: cacheManager, httpHeaders: headers, @@ -768,24 +837,24 @@ class FullScreenImageViewer extends ConsumerWidget { ), ); } - } else { + } else if (imageData != null) { try { String actualBase64; - if (imageData.startsWith('data:')) { - final commaIndex = imageData.indexOf(','); + if (imageData!.startsWith('data:')) { + final commaIndex = imageData!.indexOf(','); if (commaIndex == -1) { throw const FormatException('Invalid data URI'); } - actualBase64 = imageData.substring(commaIndex + 1); + actualBase64 = imageData!.substring(commaIndex + 1); } else { - actualBase64 = imageData; + actualBase64 = imageData!; } - final imageBytes = base64.decode(actualBase64); + final decodedBytes = base64.decode(actualBase64); // Check if SVG content - if (isSvg || _isSvgDataUrl(imageData) || _isSvgBytes(imageBytes)) { + if (isSvg || _isSvgDataUrl(imageData!) || _isSvgBytes(decodedBytes)) { imageWidget = SvgPicture.memory( - imageBytes, + decodedBytes, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) => Center( child: Icon( @@ -796,7 +865,7 @@ class FullScreenImageViewer extends ConsumerWidget { ), ); } else { - imageWidget = Image.memory(imageBytes, fit: BoxFit.contain); + imageWidget = Image.memory(decodedBytes, fit: BoxFit.contain); } } catch (e) { imageWidget = Center( @@ -807,6 +876,15 @@ class FullScreenImageViewer extends ConsumerWidget { ), ); } + } else { + // No image data available - show error + imageWidget = Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), + ); } final tokens = context.colorTokens; @@ -860,7 +938,11 @@ class FullScreenImageViewer extends ConsumerWidget { Uint8List bytes; String? fileExtension; - if (imageData.startsWith('http')) { + // If we have raw bytes, use them directly + if (imageData == null && imageBytes != null) { + bytes = imageBytes!; + fileExtension = isSvg ? 'svg' : 'png'; + } else if (imageData!.startsWith('http')) { final api = ref.read(apiServiceProvider); final authToken = ref.read(authTokenProvider3); final headers = {}; @@ -878,7 +960,7 @@ class FullScreenImageViewer extends ConsumerWidget { final client = api?.dio ?? dio.Dio(); final response = await client.get>( - imageData, + imageData!, options: dio.Options( responseType: dio.ResponseType.bytes, headers: mergedHeaders, @@ -895,7 +977,7 @@ class FullScreenImageViewer extends ConsumerWidget { fileExtension = contentType.split('/').last; if (fileExtension == 'jpeg') fileExtension = 'jpg'; } else { - final uri = Uri.tryParse(imageData); + final uri = Uri.tryParse(imageData!); final lastSegment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : ''; @@ -907,20 +989,23 @@ class FullScreenImageViewer extends ConsumerWidget { } } } - } else { - String actualBase64 = imageData; - if (imageData.startsWith('data:')) { - final commaIndex = imageData.indexOf(','); - final meta = imageData.substring(5, commaIndex); // image/png;base64 + } else if (imageData != null) { + String actualBase64 = imageData!; + if (imageData!.startsWith('data:')) { + final commaIndex = imageData!.indexOf(','); + final meta = imageData!.substring(5, commaIndex); // image/png;base64 final slashIdx = meta.indexOf('/'); final semicolonIdx = meta.indexOf(';'); if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) { final subtype = meta.substring(slashIdx + 1, semicolonIdx); fileExtension = subtype == 'jpeg' ? 'jpg' : subtype; } - actualBase64 = imageData.substring(commaIndex + 1); + actualBase64 = imageData!.substring(commaIndex + 1); } bytes = base64.decode(actualBase64); + } else { + // No image data available + return; } fileExtension ??= 'png'; diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index fc03def..43d16bb 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -13,6 +13,7 @@ import '../providers/chat_providers.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../tools/providers/tools_providers.dart'; +import '../utils/file_utils.dart'; // Pre-compiled regex for extracting file IDs from URLs (performance optimization) // Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats @@ -84,16 +85,21 @@ class _UserMessageBubbleState extends ConsumerState { final allFiles = widget.message.files!; // Separate images and non-image files + // Match OpenWebUI: type === 'image' OR content_type starts with 'image/' final imageFiles = allFiles .where( (file) => - file is Map && file['type'] == 'image' && file['url'] != null, + file is Map && + isImageFile(file) && + getFileUrl(file) != null, ) .toList(); final nonImageFiles = allFiles .where( (file) => - file is Map && file['type'] != 'image' && file['url'] != null, + file is Map && + !isImageFile(file) && + getFileUrl(file) != null, ) .toList(); @@ -131,7 +137,8 @@ class _UserMessageBubbleState extends ConsumerState { Widget _buildFileImageLayout(List imageFiles, int imageCount) { if (imageCount == 1) { final file = imageFiles[0]; - final String imageUrl = file['url'] as String; + final imageUrl = getFileUrl(file); + if (imageUrl == null) return const SizedBox.shrink(); return Row( key: ValueKey('user_file_single_$imageUrl'), mainAxisAlignment: MainAxisAlignment.end, @@ -175,7 +182,8 @@ class _UserMessageBubbleState extends ConsumerState { children: imageFiles.asMap().entries.map((entry) { final index = entry.key; final file = entry.value; - final String imageUrl = file['url'] as String; + final imageUrl = getFileUrl(file); + if (imageUrl == null) return const SizedBox.shrink(); return Padding( padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs), child: Container( @@ -223,7 +231,8 @@ class _UserMessageBubbleState extends ConsumerState { spacing: Spacing.xs, runSpacing: Spacing.xs, children: imageFiles.map((file) { - final String imageUrl = file['url'] as String; + final imageUrl = getFileUrl(file); + if (imageUrl == null) return const SizedBox.shrink(); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppBorderRadius.md),