diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index fdbd094..c759dd3 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1772,41 +1772,44 @@ Future _sendMessageInternal( final contextFiles = _contextAttachmentsToFiles(contextAttachments); // Convert attachments to files format for web client compatibility + // Process in parallel for better performance (fixes #310 - loading indicator) + // while preserving original attachment order final attachmentFiles = >[]; if (attachments != null && !reviewerMode && api != null) { - for (final attachment in attachments) { - // Data URLs are images - store inline + // Process all attachments in parallel while preserving order + final fileInfoFutures = attachments.map((attachment) async { + // Data URLs are images - return immediately (no API call needed) if (attachment.startsWith('data:image/')) { - attachmentFiles.add({'type': 'image', 'url': attachment}); - } else { - // Server file ID - fetch info and create file entry - // Match web client format: {type, id, name, url, size, collection_name} - try { - final fileInfo = await api.getFileInfo(attachment); - final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'file'; - final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size']; - final collectionName = - fileInfo['meta']?['collection_name'] ?? - fileInfo['collection_name']; - attachmentFiles.add({ - 'type': 'file', - 'id': attachment, - 'name': fileName, - 'url': '/api/v1/files/$attachment', - if (fileSize != null) 'size': fileSize, - if (collectionName != null) 'collection_name': collectionName, - }); - } catch (_) { - // If we can't fetch info, store minimal file entry with placeholder name - attachmentFiles.add({ - 'type': 'file', - 'id': attachment, - 'name': 'file', - 'url': '/api/v1/files/$attachment', - }); - } + return {'type': 'image', 'url': attachment}; } - } + // Server file ID - fetch info + try { + final fileInfo = await api.getFileInfo(attachment); + final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'file'; + final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size']; + final collectionName = + fileInfo['meta']?['collection_name'] ?? fileInfo['collection_name']; + return { + 'type': 'file', + 'id': attachment, + 'name': fileName, + 'url': '/api/v1/files/$attachment', + if (fileSize != null) 'size': fileSize, + if (collectionName != null) 'collection_name': collectionName, + }; + } catch (_) { + // If we can't fetch info, store minimal file entry + return { + 'type': 'file', + 'id': attachment, + 'name': 'file', + 'url': '/api/v1/files/$attachment', + }; + } + }); + // Future.wait preserves order - results match input order + final results = await Future.wait(fileInfoFutures); + attachmentFiles.addAll(results); } else if (attachments != null) { // Reviewer mode or no API - only handle images (server files need API) for (final attachment in attachments) { @@ -1938,21 +1941,21 @@ Future _sendMessageInternal( activeConversation = updated; } - // We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode) + // Add assistant placeholder immediately after user message to show typing + // indicator right away (fixes #310 - loading animation not showing) + final String assistantMessageId = const Uuid().v4(); + final assistantPlaceholder = ChatMessage( + id: assistantMessageId, + role: 'assistant', + content: '', + timestamp: DateTime.now(), + model: selectedModel.id, + isStreaming: true, + ); + ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder); // Reviewer mode: simulate a response locally and return if (reviewerMode) { - // Add assistant message placeholder - final assistantMessage = ChatMessage( - id: const Uuid().v4(), - role: 'assistant', - content: '', - timestamp: DateTime.now(), - model: selectedModel.id, - isStreaming: true, - ); - ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); - // Check if there are attachments String? filename; if (attachments != null && attachments.isNotEmpty) { @@ -2066,21 +2069,8 @@ Future _sendMessageInternal( : null; try { - // Pre-seed assistant skeleton on server to ensure correct chain - // Generate assistant message id now (must be consistent across client/server) - final String assistantMessageId = const Uuid().v4(); - - // Add assistant placeholder locally before sending - final assistantPlaceholder = ChatMessage( - id: assistantMessageId, - role: 'assistant', - content: '', - timestamp: DateTime.now(), - model: selectedModel.id, - isStreaming: true, - ); - ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder); - + // Assistant placeholder was already added above (after user message) + // to show typing indicator immediately. Sync conversation state to server. // Sync conversation state to ensure WebUI can load conversation history try { final activeConvForSeed = ref.read(activeConversationProvider); diff --git a/lib/features/chat/services/file_attachment_service.dart b/lib/features/chat/services/file_attachment_service.dart index c12b720..16ea8c2 100644 --- a/lib/features/chat/services/file_attachment_service.dart +++ b/lib/features/chat/services/file_attachment_service.dart @@ -10,6 +10,10 @@ import 'package:path/path.dart' as path; import '../../../core/providers/app_providers.dart'; import '../../../core/utils/debug_logger.dart'; +/// Size threshold for optimizing images to WebP (200KB). +/// Images larger than this will be converted to WebP for better compression. +const int _webpOptimizationThreshold = 200 * 1024; + /// Standard web image formats that LLMs can process directly. const Set _standardImageFormats = { '.jpg', @@ -17,17 +21,12 @@ const Set _standardImageFormats = { '.png', '.gif', '.webp', - '.bmp', }; -/// iOS-specific formats that need conversion to JPEG before LLM submission. -const Set _iosImageFormats = { +/// Formats that should always be converted to WebP (not widely supported). +const Set _alwaysConvertFormats = { '.heic', '.heif', -}; - -/// RAW image formats that need conversion to JPEG before LLM submission. -const Set _rawImageFormats = { '.dng', '.raw', '.cr2', @@ -35,62 +34,116 @@ const Set _rawImageFormats = { '.arw', '.orf', '.rw2', + '.bmp', +}; + +/// Formats that benefit from WebP conversion when large. +const Set _optimizableFormats = { + '.jpg', + '.jpeg', + '.png', +}; + +/// Formats that should never be converted (animation, already optimal). +const Set _preserveFormats = { + '.gif', + '.webp', }; /// All supported image formats (both standard and those requiring conversion). const Set allSupportedImageFormats = { ..._standardImageFormats, - ..._iosImageFormats, - ..._rawImageFormats, + ..._alwaysConvertFormats, }; -/// Returns true if the extension requires conversion to a standard format. -bool _needsConversion(String extension) { - return _iosImageFormats.contains(extension) || - _rawImageFormats.contains(extension); +/// Returns true if the extension always requires conversion to WebP. +bool _alwaysNeedsConversion(String extension) { + return _alwaysConvertFormats.contains(extension); } -/// Converts an image file to a base64 data URL. +/// Returns true if the format can benefit from WebP optimization. +bool _canOptimize(String extension) { + return _optimizableFormats.contains(extension); +} + +/// Returns true if the format should be preserved as-is. +bool _shouldPreserve(String extension) { + return _preserveFormats.contains(extension); +} + +/// Converts an image file to a base64 data URL with smart optimization. /// This is a standalone utility used by both FileAttachmentService and TaskWorker. /// -/// Handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, CR2, etc.) -/// by converting them to JPEG before encoding. +/// Optimization strategy: +/// - HEIC/HEIF/RAW/BMP → Always convert to WebP +/// - Large JPEG/PNG (>200KB) → Convert to WebP for better compression +/// - Small JPEG/PNG (<200KB) → Pass through as-is +/// - GIF → Preserve (maintains animation) +/// - WebP → Preserve (already optimal) /// -/// Returns null if conversion fails. +/// Returns null if conversion fails for formats requiring conversion. Future convertImageFileToDataUrl(File imageFile) async { try { final ext = path.extension(imageFile.path).toLowerCase(); + final fileSize = await imageFile.length(); - // Check if we need to convert the image format - if (_needsConversion(ext)) { + // Formats that must always be converted (HEIC, RAW, BMP, etc.) + if (_alwaysNeedsConversion(ext)) { DebugLogger.log( - 'Converting image from $ext to JPEG', + 'Converting image from $ext to WebP (required)', scope: 'attachments', - data: {'path': imageFile.path}, + data: {'path': imageFile.path, 'size': fileSize}, ); - final convertedBytes = await _convertImageToJpeg(imageFile); + final convertedBytes = await _convertToWebP(imageFile); if (convertedBytes != null) { - return 'data:image/jpeg;base64,${base64Encode(convertedBytes)}'; + return 'data:image/webp;base64,${base64Encode(convertedBytes)}'; } - // Conversion failed - return null rather than sending unusable raw data DebugLogger.warning( 'Conversion failed for $ext format, cannot process image', ); return null; } - // Standard format - read directly - final bytes = await imageFile.readAsBytes(); + // Formats that should be preserved as-is (GIF, WebP) + if (_shouldPreserve(ext)) { + final bytes = await imageFile.readAsBytes(); + final mimeType = ext == '.gif' ? 'image/gif' : 'image/webp'; + return 'data:$mimeType;base64,${base64Encode(bytes)}'; + } + // Optimizable formats (JPEG, PNG) - convert if large + if (_canOptimize(ext) && fileSize > _webpOptimizationThreshold) { + DebugLogger.log( + 'Optimizing large image from $ext to WebP', + scope: 'attachments', + data: {'path': imageFile.path, 'size': fileSize}, + ); + + final convertedBytes = await _convertToWebP(imageFile); + if (convertedBytes != null) { + final savings = fileSize - convertedBytes.length; + final savingsPercent = (savings / fileSize * 100).toStringAsFixed(1); + DebugLogger.log( + 'WebP optimization saved $savingsPercent%', + scope: 'attachments', + data: { + 'originalSize': fileSize, + 'newSize': convertedBytes.length, + 'saved': savings, + }, + ); + return 'data:image/webp;base64,${base64Encode(convertedBytes)}'; + } + // Fall through to pass-through if conversion fails + } + + // Pass through as-is (small images or unknown formats) + final bytes = await imageFile.readAsBytes(); String mimeType = 'image/png'; if (ext == '.jpg' || ext == '.jpeg') { mimeType = 'image/jpeg'; - } else if (ext == '.gif') { - mimeType = 'image/gif'; - } else if (ext == '.webp') { - mimeType = 'image/webp'; } return 'data:$mimeType;base64,${base64Encode(bytes)}'; @@ -100,20 +153,19 @@ Future convertImageFileToDataUrl(File imageFile) async { } } -/// Converts an image file to JPEG bytes using flutter_image_compress. -/// This handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, etc.) -Future?> _convertImageToJpeg(File imageFile) async { +/// Converts an image file to WebP bytes using flutter_image_compress. +/// WebP provides better compression than JPEG while maintaining quality. +Future?> _convertToWebP(File imageFile) async { try { - // Use flutter_image_compress for native iOS/Android conversion final result = await FlutterImageCompress.compressWithFile( imageFile.absolute.path, - format: CompressFormat.jpeg, - quality: 90, + format: CompressFormat.webp, + quality: 85, ); if (result != null && result.isNotEmpty) { DebugLogger.log( - 'Image converted successfully', + 'Image converted to WebP successfully', scope: 'attachments', data: { 'originalPath': imageFile.path, @@ -126,7 +178,7 @@ Future?> _convertImageToJpeg(File imageFile) async { return null; } catch (e) { DebugLogger.error( - 'image-conversion-failed', + 'webp-conversion-failed', scope: 'attachments', error: e, ); @@ -163,7 +215,7 @@ String _deriveDisplayName({ String _timestampedName({required String prefix, required String extension}) { final DateTime now = DateTime.now(); String two(int value) => value.toString().padLeft(2, '0'); - final String ext = extension.isNotEmpty ? extension : '.jpg'; + final String ext = extension.isNotEmpty ? extension : '.webp'; final String timestamp = '${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}'; return '${prefix}_$timestamp$ext'; @@ -295,7 +347,9 @@ class FileAttachmentService { } } - // Compress image similar to OpenWebUI's implementation + /// Compresses and resizes an image data URL. + /// Uses PNG format for the resize operation (dart:ui limitation), + /// then converts to WebP for optimal file size. Future compressImage( String imageDataUrl, int? maxWidth, @@ -314,7 +368,7 @@ class FileAttachmentService { : imageDataUrl, }, ); - return imageDataUrl; // Return original if format is invalid + return imageDataUrl; } final data = parts[1]; final bytes = base64Decode(data); @@ -330,7 +384,7 @@ class FileAttachmentService { // Calculate new dimensions maintaining aspect ratio if (maxWidth != null && maxHeight != null) { if (width <= maxWidth && height <= maxHeight) { - return imageDataUrl; // No compression needed + return imageDataUrl; } if (width / height > maxWidth / maxHeight) { @@ -342,19 +396,19 @@ class FileAttachmentService { } } else if (maxWidth != null) { if (width <= maxWidth) { - return imageDataUrl; // No compression needed + return imageDataUrl; } height = ((maxWidth * height) / width).round(); width = maxWidth; } else if (maxHeight != null) { if (height <= maxHeight) { - return imageDataUrl; // No compression needed + return imageDataUrl; } width = ((maxHeight * width) / height).round(); height = maxHeight; } - // Create compressed image + // Create resized image (dart:ui only supports PNG output) final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); @@ -366,22 +420,28 @@ class FileAttachmentService { ); final picture = recorder.endRecording(); - final compressedImage = await picture.toImage(width, height); - final byteData = await compressedImage.toByteData( + final resizedImage = await picture.toImage(width, height); + final byteData = await resizedImage.toByteData( format: ui.ImageByteFormat.png, ); - final compressedBytes = byteData!.buffer.asUint8List(); + final pngBytes = byteData!.buffer.asUint8List(); - // Convert back to data URL - final compressedBase64 = base64Encode(compressedBytes); - return 'data:image/png;base64,$compressedBase64'; + // Convert PNG to WebP for better compression + final webpBytes = await FlutterImageCompress.compressWithList( + pngBytes, + format: CompressFormat.webp, + quality: 85, + ); + + final compressedBase64 = base64Encode(webpBytes); + return 'data:image/webp;base64,$compressedBase64'; } catch (e) { DebugLogger.error( 'compress-failed', scope: 'attachments/image', error: e, ); - return imageDataUrl; // Return original if compression fails + return imageDataUrl; } } diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 4270fa4..9952200 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -593,7 +593,6 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildSegmentedContent() { final children = []; bool firstToolSpacerAdded = false; - bool hasNonTextSegment = false; int idx = 0; for (final seg in _segments) { if (seg.isTool && seg.toolCall != null) { @@ -603,16 +602,10 @@ class _AssistantMessageWidgetState extends ConsumerState firstToolSpacerAdded = true; } children.add(_buildToolCallTile(seg.toolCall!)); - hasNonTextSegment = true; } else if (seg.isReasoning && seg.reasoning != null) { children.add(_buildReasoningTile(seg.reasoning!, idx)); - hasNonTextSegment = true; } else if ((seg.text ?? '').trim().isNotEmpty) { - // Add spacing before text content if it follows non-text segments - if (hasNonTextSegment) { - children.add(const SizedBox(height: Spacing.sm)); - hasNonTextSegment = false; - } + // No extra spacing needed - reasoning/tool tiles have bottom padding children.add(_buildEnhancedMarkdownContent(seg.text!)); } idx++; @@ -704,12 +697,19 @@ class _AssistantMessageWidgetState extends ConsumerState return false; } - final hasVisibleStatus = widget.message.statusHistory + // Check if there's a pending (not done) visible status - those have shimmer + // so we don't need the typing indicator. But if all visible statuses are + // done (e.g., "Retrieved 1 source"), show typing indicator to indicate + // the model is still working on generating a response. + final visibleStatuses = widget.message.statusHistory .where((status) => status.hidden != true) - .isNotEmpty; - if (hasVisibleStatus) { + .toList(); + final hasPendingStatus = visibleStatuses.any((status) => status.done != true); + if (hasPendingStatus) { + // Pending status has shimmer effect, no need for typing indicator return false; } + // If all statuses are done but no content yet, show typing indicator final hasFollowUps = widget.message.followUps.isNotEmpty; if (hasFollowUps) { diff --git a/lib/shared/widgets/markdown/citation_badge.dart b/lib/shared/widgets/markdown/citation_badge.dart index 49b1ca0..7a92676 100644 --- a/lib/shared/widgets/markdown/citation_badge.dart +++ b/lib/shared/widgets/markdown/citation_badge.dart @@ -142,7 +142,7 @@ class CitationBadge extends StatelessWidget { SourceHelper.launchSourceUrl(url); } }, - borderRadius: BorderRadius.circular(AppBorderRadius.chip), + borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, @@ -150,33 +150,22 @@ class CitationBadge extends StatelessWidget { ), margin: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(AppBorderRadius.chip), + color: theme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), border: Border.all( - color: theme.cardBorder.withValues(alpha: 0.5), - width: BorderWidth.thin, + color: theme.dividerColor.withValues(alpha: 0.5), + width: 1, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.link_rounded, - size: 10, - color: theme.textSecondary.withValues(alpha: 0.7), - ), - const SizedBox(width: Spacing.xxs), - Text( - displayTitle, - style: TextStyle( - fontSize: AppTypography.labelSmall, - fontWeight: FontWeight.w500, - color: theme.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + child: Text( + displayTitle, + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ),