diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index 4cf2c62..c36d6ce 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -272,6 +272,10 @@ Map _parseOpenWebUIMessageToJson( }; if (entry['name'] != null) fileMap['name'] = entry['name']; if (entry['size'] != null) fileMap['size'] = entry['size']; + final headers = _coerceStringMap(entry['headers']); + if (headers != null && headers.isNotEmpty) { + fileMap['headers'] = headers; + } allFiles.add(fileMap); final url = entry['url'].toString(); @@ -388,6 +392,24 @@ List> _parseStatusHistoryField(dynamic raw) { return const >[]; } +Map? _coerceStringMap(dynamic raw) { + if (raw is Map) { + final result = {}; + raw.forEach((key, value) { + final keyString = key?.toString(); + final valueString = value?.toString(); + if (keyString != null && + keyString.isNotEmpty && + valueString != null && + valueString.isNotEmpty) { + result[keyString] = valueString; + } + }); + return result.isEmpty ? null : result; + } + return null; +} + List _coerceStringList(dynamic raw) { if (raw is List) { return raw diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 320d7d5..9aced83 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -981,8 +981,8 @@ Future _getFileAsBase64(dynamic api, String fileId) async { final ext = fileName.toLowerCase().split('.').last; - // Only process image files - if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) { + // Only process image files (including SVG) + if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) { return null; } @@ -1039,6 +1039,8 @@ Future> _buildMessagePayloadWithAttachments({ mimeType = 'image/gif'; } else if (ext == 'webp') { mimeType = 'image/webp'; + } else if (ext == 'svg') { + mimeType = 'image/svg+xml'; } final dataUrl = 'data:$mimeType;base64,$base64Data'; diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 570ee3f..f1e3dd7 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -1063,6 +1063,7 @@ class _AssistantMessageWidgetState extends ConsumerState ), disableAnimation: false, // Keep animations enabled to prevent black display + httpHeaders: _headersForFile(imageFiles[0]), ); }, ), @@ -1087,12 +1088,31 @@ class _AssistantMessageWidgetState extends ConsumerState ), disableAnimation: false, // Keep animations enabled to prevent black display + httpHeaders: _headersForFile(file), ); }).toList(), ), ); } + Map? _headersForFile(dynamic file) { + if (file is! Map) return null; + final rawHeaders = file['headers']; + if (rawHeaders is! Map) return null; + final result = {}; + rawHeaders.forEach((key, value) { + final keyString = key?.toString(); + final valueString = value?.toString(); + if (keyString != null && + keyString.isNotEmpty && + valueString != null && + valueString.isNotEmpty) { + result[keyString] = valueString; + } + }); + return result.isEmpty ? null : result; + } + Widget _buildNonImageFiles(List nonImageFiles) { return Wrap( spacing: Spacing.sm, diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart index a570aca..94359e4 100644 --- a/lib/features/chat/widgets/enhanced_image_attachment.dart +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:dio/dio.dart' as dio; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -22,6 +23,7 @@ final _globalImageCache = {}; final _globalLoadingStates = {}; final _globalErrorStates = {}; final _globalImageBytesCache = {}; +final _globalSvgStates = {}; final _base64WhitespacePattern = RegExp(r'\s'); Uint8List _decodeImageData(String data) { @@ -37,6 +39,55 @@ Uint8List _decodeImageData(String data) { return base64.decode(payload); } +/// Checks if data URL or content indicates SVG format. +bool _isSvgDataUrl(String data) { + final lower = data.toLowerCase(); + return lower.startsWith('data:image/svg+xml'); +} + +/// Checks if a URL points to an SVG file. +bool _isSvgUrl(String url) { + final lowerUrl = url.toLowerCase(); + + // Check for .svg file extension (with or without query string) + final queryIndex = lowerUrl.indexOf('?'); + final pathPart = queryIndex >= 0 + ? lowerUrl.substring(0, queryIndex) + : lowerUrl; + if (pathPart.endsWith('.svg')) return true; + + // Check for SVG MIME type in query parameters only (not in path) + // This handles cases like ?format=image/svg+xml or &type=image/svg+xml + if (queryIndex >= 0) { + final queryPart = lowerUrl.substring(queryIndex); + if (queryPart.contains('image/svg+xml')) return true; + } + + return false; +} + +/// Checks if decoded bytes represent SVG content by looking for the SVG tag. +bool _isSvgBytes(Uint8List bytes) { + // Check first 1KB for SVG tag (not just XML declaration, which is too broad) + final checkLength = bytes.length < 1024 ? bytes.length : 1024; + final header = utf8.decode( + bytes.sublist(0, checkLength), + allowMalformed: true, + ); + return header.toLowerCase().contains('? _mergeHeaders( + Map? defaults, + Map? overrides, +) { + if ((defaults == null || defaults.isEmpty) && + (overrides == null || overrides.isEmpty)) { + return null; + } + return {...?defaults, ...?overrides}; +} + class EnhancedImageAttachment extends ConsumerStatefulWidget { final String attachmentId; final bool isMarkdownFormat; @@ -44,6 +95,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget { final BoxConstraints? constraints; final bool isUserMessage; final bool disableAnimation; + final Map? httpHeaders; const EnhancedImageAttachment({ super.key, @@ -53,6 +105,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget { this.constraints, this.isUserMessage = false, this.disableAnimation = false, + this.httpHeaders, }); @override @@ -68,6 +121,7 @@ class _EnhancedImageAttachmentState bool _isLoading = true; String? _errorMessage; bool _isDecoding = false; + bool _isSvg = false; late final String _heroTag; // Removed unused animation and state flags @@ -107,10 +161,12 @@ class _EnhancedImageAttachmentState if (_globalImageCache.containsKey(widget.attachmentId)) { final cachedData = _globalImageCache[widget.attachmentId]!; final cachedBytes = _globalImageBytesCache[widget.attachmentId]; + final cachedIsSvg = _globalSvgStates[widget.attachmentId] ?? false; if (mounted) { setState(() { _cachedImageData = cachedData; _cachedBytes = cachedBytes; + _isSvg = cachedIsSvg; _isLoading = cachedBytes == null && !_isRemoteContent(cachedData); }); } @@ -129,13 +185,18 @@ class _EnhancedImageAttachmentState final attachmentId = widget.attachmentId; if (attachmentId.startsWith('data:') || attachmentId.startsWith('http')) { + // Detect SVG from data URL or HTTP URL + final isSvgContent = + _isSvgDataUrl(attachmentId) || _isSvgUrl(attachmentId); _globalImageCache[attachmentId] = attachmentId; _globalLoadingStates[attachmentId] = false; + _globalSvgStates[attachmentId] = isSvgContent; final cachedBytes = _globalImageBytesCache[attachmentId]; if (mounted) { setState(() { _cachedImageData = attachmentId; _cachedBytes = cachedBytes; + _isSvg = isSvgContent; _isLoading = cachedBytes == null && !_isRemoteContent(attachmentId); }); } @@ -149,12 +210,15 @@ class _EnhancedImageAttachmentState final api = ref.read(apiServiceProvider); if (api != null) { final fullUrl = api.baseUrl + attachmentId; + final isSvgContent = _isSvgUrl(fullUrl); _globalImageCache[attachmentId] = fullUrl; _globalLoadingStates[attachmentId] = false; + _globalSvgStates[attachmentId] = isSvgContent; if (mounted) { setState(() { _cachedImageData = fullUrl; _cachedBytes = null; + _isSvg = isSvgContent; _isLoading = false; }); } @@ -184,10 +248,14 @@ class _EnhancedImageAttachmentState return; } + // Track if this is an SVG file based on extension + final isSvgFile = ext == 'svg'; + final fileContent = await api.getFileContent(attachmentId); _globalImageCache[attachmentId] = fileContent; _globalLoadingStates[attachmentId] = false; + _globalSvgStates[attachmentId] = isSvgFile; if (_globalImageCache.length > 50) { final firstKey = _globalImageCache.keys.first; @@ -195,12 +263,14 @@ class _EnhancedImageAttachmentState _globalLoadingStates.remove(firstKey); _globalErrorStates.remove(firstKey); _globalImageBytesCache.remove(firstKey); + _globalSvgStates.remove(firstKey); } if (mounted) { setState(() { _cachedImageData = fileContent; _cachedBytes = null; + _isSvg = isSvgFile; _isLoading = !_isRemoteContent(fileContent); }); } @@ -234,9 +304,18 @@ class _EnhancedImageAttachmentState debugLabel: 'decode_image', ); _globalImageBytesCache[widget.attachmentId] = bytes; + + // Use byte content as authoritative SVG detection when positive, but + // preserve prior true-hints (e.g., from file extension) if detection fails. + final previousHint = _globalSvgStates[widget.attachmentId] ?? _isSvg; + final detectedSvg = _isSvgBytes(bytes) || _isSvgDataUrl(data); + final isSvgContent = detectedSvg ? true : previousHint; + _globalSvgStates[widget.attachmentId] = isSvgContent; + if (!mounted) return; setState(() { _cachedBytes = bytes; + _isSvg = isSvgContent; _isLoading = false; }); } on FormatException { @@ -255,6 +334,7 @@ class _EnhancedImageAttachmentState _globalLoadingStates[widget.attachmentId] = false; _globalImageCache.remove(widget.attachmentId); _globalImageBytesCache.remove(widget.attachmentId); + _globalSvgStates.remove(widget.attachmentId); if (!mounted) { return; } @@ -297,11 +377,14 @@ class _EnhancedImageAttachmentState } // Handle different image data formats + // Include fallback URL/data detection to match FullScreenImageViewer behavior Widget imageWidget; if (_cachedImageData!.startsWith('http')) { - imageWidget = _buildNetworkImage(); + final isSvgContent = _isSvg || _isSvgUrl(_cachedImageData!); + imageWidget = isSvgContent ? _buildNetworkSvg() : _buildNetworkImage(); } else { - imageWidget = _buildBase64Image(); + final isSvgContent = _isSvg || _isSvgDataUrl(_cachedImageData!); + imageWidget = isSvgContent ? _buildBase64Svg() : _buildBase64Image(); } // Always show the image without fade transitions during streaming to prevent black display @@ -415,7 +498,8 @@ class _EnhancedImageAttachmentState Widget _buildNetworkImage() { // Get authentication headers if available - final headers = buildImageHeadersFromWidgetRef(ref); + final defaultHeaders = buildImageHeadersFromWidgetRef(ref); + final headers = _mergeHeaders(defaultHeaders, widget.httpHeaders); final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); final imageWidget = CachedNetworkImage( @@ -446,6 +530,40 @@ class _EnhancedImageAttachmentState return _wrapImage(imageWidget); } + Widget _buildNetworkSvg() { + // Get authentication headers if available + final defaultHeaders = buildImageHeadersFromWidgetRef(ref); + final headers = _mergeHeaders(defaultHeaders, widget.httpHeaders); + + final svgWidget = SvgPicture.network( + _cachedImageData!, + key: ValueKey('svg_${widget.attachmentId}'), + fit: BoxFit.contain, + headers: headers, + placeholderBuilder: (context) => Container( + constraints: widget.constraints, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + strokeWidth: 2, + ), + ), + ), + errorBuilder: (context, error, stackTrace) { + _errorMessage = AppLocalizations.of( + context, + )!.failedToLoadImage(error.toString()); + return _buildErrorState(); + }, + ); + + return _wrapImage(svgWidget); + } + Widget _buildBase64Image() { final bytes = _cachedBytes; if (bytes == null) { @@ -466,6 +584,25 @@ class _EnhancedImageAttachmentState return _wrapImage(imageWidget); } + Widget _buildBase64Svg() { + final bytes = _cachedBytes; + if (bytes == null) { + return _buildLoadingState(); + } + + final svgWidget = SvgPicture.memory( + bytes, + key: ValueKey('svg_${widget.attachmentId}'), + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + _errorMessage = AppLocalizations.of(context)!.failedToDecodeImage; + return _buildErrorState(); + }, + ); + + return _wrapImage(svgWidget); + } + Widget _wrapImage(Widget imageWidget) { final wrappedImage = Container( constraints: @@ -523,8 +660,12 @@ class _EnhancedImageAttachmentState Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (context) => - FullScreenImageViewer(imageData: _cachedImageData!, tag: _heroTag), + builder: (context) => FullScreenImageViewer( + imageData: _cachedImageData!, + tag: _heroTag, + isSvg: _isSvg, + customHeaders: widget.httpHeaders, + ), ), ); } @@ -533,11 +674,15 @@ class _EnhancedImageAttachmentState class FullScreenImageViewer extends ConsumerWidget { final String imageData; final String tag; + final bool isSvg; + final Map? customHeaders; const FullScreenImageViewer({ super.key, required this.imageData, required this.tag, + this.isSvg = false, + this.customHeaders, }); @override @@ -546,38 +691,78 @@ class FullScreenImageViewer extends ConsumerWidget { if (imageData.startsWith('http')) { // Get authentication headers if available - final headers = buildImageHeadersFromWidgetRef(ref); + final defaultHeaders = buildImageHeadersFromWidgetRef(ref); + final headers = _mergeHeaders(defaultHeaders, customHeaders); - final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); - imageWidget = CachedNetworkImage( - imageUrl: imageData, - fit: BoxFit.contain, - cacheManager: cacheManager, - httpHeaders: headers, - placeholder: (context, url) => Center( - child: CircularProgressIndicator( - color: context.conduitTheme.buttonPrimary, + if (isSvg || _isSvgUrl(imageData)) { + imageWidget = SvgPicture.network( + imageData, + fit: BoxFit.contain, + headers: headers, + placeholderBuilder: (context) => Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + ), ), - ), - errorWidget: (context, url, error) => Center( - child: Icon( - Icons.error_outline, - color: context.conduitTheme.error, - size: 48, + errorBuilder: (context, error, stackTrace) => Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), ), - ), - ); + ); + } else { + final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); + imageWidget = CachedNetworkImage( + imageUrl: imageData, + fit: BoxFit.contain, + cacheManager: cacheManager, + httpHeaders: headers, + placeholder: (context, url) => Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + ), + ), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), + ), + ); + } } else { try { String actualBase64; if (imageData.startsWith('data:')) { final commaIndex = imageData.indexOf(','); + if (commaIndex == -1) { + throw const FormatException('Invalid data URI'); + } actualBase64 = imageData.substring(commaIndex + 1); } else { actualBase64 = imageData; } final imageBytes = base64.decode(actualBase64); - imageWidget = Image.memory(imageBytes, fit: BoxFit.contain); + + // Check if SVG content + if (isSvg || _isSvgDataUrl(imageData) || _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); + } } catch (e) { imageWidget = Center( child: Icon( @@ -654,13 +839,14 @@ class FullScreenImageViewer extends ConsumerWidget { if (api != null && api.serverConfig.customHeaders.isNotEmpty) { headers.addAll(api.serverConfig.customHeaders); } + final mergedHeaders = _mergeHeaders(headers, customHeaders); final client = api?.dio ?? dio.Dio(); final response = await client.get>( imageData, options: dio.Options( responseType: dio.ResponseType.bytes, - headers: headers.isNotEmpty ? headers : null, + headers: mergedHeaders, ), ); final data = response.data; diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 896d35c..3848265 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -150,6 +150,7 @@ class _UserMessageBubbleState extends ConsumerState { maxHeight: 350, ), disableAnimation: widget.isStreaming, + httpHeaders: _headersForFile(imageFiles[0]), ), ), ), @@ -191,6 +192,7 @@ class _UserMessageBubbleState extends ConsumerState { maxHeight: 180, ), disableAnimation: widget.isStreaming, + httpHeaders: _headersForFile(entry.value), ), ), ), @@ -232,6 +234,7 @@ class _UserMessageBubbleState extends ConsumerState { maxHeight: imageCount == 3 ? 135 : 90, ), disableAnimation: widget.isStreaming, + httpHeaders: _headersForFile(file), ), ), ); @@ -401,6 +404,24 @@ class _UserMessageBubbleState extends ConsumerState { ); } + Map? _headersForFile(dynamic file) { + if (file is! Map) return null; + final rawHeaders = file['headers']; + if (rawHeaders is! Map) return null; + final result = {}; + rawHeaders.forEach((key, value) { + final keyString = key?.toString(); + final valueString = value?.toString(); + if (keyString != null && + keyString.isNotEmpty && + valueString != null && + valueString.isNotEmpty) { + result[keyString] = valueString; + } + }); + return result.isEmpty ? null : result; + } + // Assistant-only helpers removed; this widget renders only user bubbles. @override diff --git a/pubspec.lock b/pubspec.lock index 3c31925..66a9119 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -652,7 +652,7 @@ packages: source: hosted version: "0.1.3" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" diff --git a/pubspec.yaml b/pubspec.yaml index 8d4dacb..74dd202 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,7 @@ dependencies: flutter_callkit_incoming: ^3.0.0 flutter_app_intents: ^0.7.0 quick_actions: 1.1.0 + flutter_svg: ^2.2.3 # Clipboard functionality is available through flutter/services (part of Flutter SDK)