feat(chat): Improve attachment processing and loading indicator

This commit is contained in:
cogwheel
2025-12-23 12:09:43 +05:30
parent 8c38d0442f
commit 1447ddd93c
4 changed files with 185 additions and 146 deletions

View File

@@ -1772,41 +1772,44 @@ Future<void> _sendMessageInternal(
final contextFiles = _contextAttachmentsToFiles(contextAttachments); final contextFiles = _contextAttachmentsToFiles(contextAttachments);
// Convert attachments to files format for web client compatibility // 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 = <Map<String, dynamic>>[]; final attachmentFiles = <Map<String, dynamic>>[];
if (attachments != null && !reviewerMode && api != null) { if (attachments != null && !reviewerMode && api != null) {
for (final attachment in attachments) { // Process all attachments in parallel while preserving order
// Data URLs are images - store inline final fileInfoFutures = attachments.map((attachment) async {
// Data URLs are images - return immediately (no API call needed)
if (attachment.startsWith('data:image/')) { if (attachment.startsWith('data:image/')) {
attachmentFiles.add({'type': 'image', 'url': attachment}); return <String, dynamic>{'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',
});
}
} }
} // 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 <String, dynamic>{
'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 <String, dynamic>{
'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) { } else if (attachments != null) {
// Reviewer mode or no API - only handle images (server files need API) // Reviewer mode or no API - only handle images (server files need API)
for (final attachment in attachments) { for (final attachment in attachments) {
@@ -1938,21 +1941,21 @@ Future<void> _sendMessageInternal(
activeConversation = updated; 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 // Reviewer mode: simulate a response locally and return
if (reviewerMode) { 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 // Check if there are attachments
String? filename; String? filename;
if (attachments != null && attachments.isNotEmpty) { if (attachments != null && attachments.isNotEmpty) {
@@ -2066,21 +2069,8 @@ Future<void> _sendMessageInternal(
: null; : null;
try { try {
// Pre-seed assistant skeleton on server to ensure correct chain // Assistant placeholder was already added above (after user message)
// Generate assistant message id now (must be consistent across client/server) // to show typing indicator immediately. Sync conversation state to 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);
// Sync conversation state to ensure WebUI can load conversation history // Sync conversation state to ensure WebUI can load conversation history
try { try {
final activeConvForSeed = ref.read(activeConversationProvider); final activeConvForSeed = ref.read(activeConversationProvider);

View File

@@ -10,6 +10,10 @@ import 'package:path/path.dart' as path;
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../core/utils/debug_logger.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. /// Standard web image formats that LLMs can process directly.
const Set<String> _standardImageFormats = { const Set<String> _standardImageFormats = {
'.jpg', '.jpg',
@@ -17,17 +21,12 @@ const Set<String> _standardImageFormats = {
'.png', '.png',
'.gif', '.gif',
'.webp', '.webp',
'.bmp',
}; };
/// iOS-specific formats that need conversion to JPEG before LLM submission. /// Formats that should always be converted to WebP (not widely supported).
const Set<String> _iosImageFormats = { const Set<String> _alwaysConvertFormats = {
'.heic', '.heic',
'.heif', '.heif',
};
/// RAW image formats that need conversion to JPEG before LLM submission.
const Set<String> _rawImageFormats = {
'.dng', '.dng',
'.raw', '.raw',
'.cr2', '.cr2',
@@ -35,62 +34,116 @@ const Set<String> _rawImageFormats = {
'.arw', '.arw',
'.orf', '.orf',
'.rw2', '.rw2',
'.bmp',
};
/// Formats that benefit from WebP conversion when large.
const Set<String> _optimizableFormats = {
'.jpg',
'.jpeg',
'.png',
};
/// Formats that should never be converted (animation, already optimal).
const Set<String> _preserveFormats = {
'.gif',
'.webp',
}; };
/// All supported image formats (both standard and those requiring conversion). /// All supported image formats (both standard and those requiring conversion).
const Set<String> allSupportedImageFormats = { const Set<String> allSupportedImageFormats = {
..._standardImageFormats, ..._standardImageFormats,
..._iosImageFormats, ..._alwaysConvertFormats,
..._rawImageFormats,
}; };
/// Returns true if the extension requires conversion to a standard format. /// Returns true if the extension always requires conversion to WebP.
bool _needsConversion(String extension) { bool _alwaysNeedsConversion(String extension) {
return _iosImageFormats.contains(extension) || return _alwaysConvertFormats.contains(extension);
_rawImageFormats.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. /// This is a standalone utility used by both FileAttachmentService and TaskWorker.
/// ///
/// Handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, CR2, etc.) /// Optimization strategy:
/// by converting them to JPEG before encoding. /// - 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<String?> convertImageFileToDataUrl(File imageFile) async { Future<String?> convertImageFileToDataUrl(File imageFile) async {
try { try {
final ext = path.extension(imageFile.path).toLowerCase(); final ext = path.extension(imageFile.path).toLowerCase();
final fileSize = await imageFile.length();
// Check if we need to convert the image format // Formats that must always be converted (HEIC, RAW, BMP, etc.)
if (_needsConversion(ext)) { if (_alwaysNeedsConversion(ext)) {
DebugLogger.log( DebugLogger.log(
'Converting image from $ext to JPEG', 'Converting image from $ext to WebP (required)',
scope: 'attachments', 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) { 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( DebugLogger.warning(
'Conversion failed for $ext format, cannot process image', 'Conversion failed for $ext format, cannot process image',
); );
return null; return null;
} }
// Standard format - read directly // Formats that should be preserved as-is (GIF, WebP)
final bytes = await imageFile.readAsBytes(); 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'; String mimeType = 'image/png';
if (ext == '.jpg' || ext == '.jpeg') { if (ext == '.jpg' || ext == '.jpeg') {
mimeType = 'image/jpeg'; mimeType = 'image/jpeg';
} else if (ext == '.gif') {
mimeType = 'image/gif';
} else if (ext == '.webp') {
mimeType = 'image/webp';
} }
return 'data:$mimeType;base64,${base64Encode(bytes)}'; return 'data:$mimeType;base64,${base64Encode(bytes)}';
@@ -100,20 +153,19 @@ Future<String?> convertImageFileToDataUrl(File imageFile) async {
} }
} }
/// Converts an image file to JPEG bytes using flutter_image_compress. /// Converts an image file to WebP bytes using flutter_image_compress.
/// This handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, etc.) /// WebP provides better compression than JPEG while maintaining quality.
Future<List<int>?> _convertImageToJpeg(File imageFile) async { Future<List<int>?> _convertToWebP(File imageFile) async {
try { try {
// Use flutter_image_compress for native iOS/Android conversion
final result = await FlutterImageCompress.compressWithFile( final result = await FlutterImageCompress.compressWithFile(
imageFile.absolute.path, imageFile.absolute.path,
format: CompressFormat.jpeg, format: CompressFormat.webp,
quality: 90, quality: 85,
); );
if (result != null && result.isNotEmpty) { if (result != null && result.isNotEmpty) {
DebugLogger.log( DebugLogger.log(
'Image converted successfully', 'Image converted to WebP successfully',
scope: 'attachments', scope: 'attachments',
data: { data: {
'originalPath': imageFile.path, 'originalPath': imageFile.path,
@@ -126,7 +178,7 @@ Future<List<int>?> _convertImageToJpeg(File imageFile) async {
return null; return null;
} catch (e) { } catch (e) {
DebugLogger.error( DebugLogger.error(
'image-conversion-failed', 'webp-conversion-failed',
scope: 'attachments', scope: 'attachments',
error: e, error: e,
); );
@@ -163,7 +215,7 @@ String _deriveDisplayName({
String _timestampedName({required String prefix, required String extension}) { String _timestampedName({required String prefix, required String extension}) {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
String two(int value) => value.toString().padLeft(2, '0'); 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 = final String timestamp =
'${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}'; '${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}';
return '${prefix}_$timestamp$ext'; 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<String> compressImage( Future<String> compressImage(
String imageDataUrl, String imageDataUrl,
int? maxWidth, int? maxWidth,
@@ -314,7 +368,7 @@ class FileAttachmentService {
: imageDataUrl, : imageDataUrl,
}, },
); );
return imageDataUrl; // Return original if format is invalid return imageDataUrl;
} }
final data = parts[1]; final data = parts[1];
final bytes = base64Decode(data); final bytes = base64Decode(data);
@@ -330,7 +384,7 @@ class FileAttachmentService {
// Calculate new dimensions maintaining aspect ratio // Calculate new dimensions maintaining aspect ratio
if (maxWidth != null && maxHeight != null) { if (maxWidth != null && maxHeight != null) {
if (width <= maxWidth && height <= maxHeight) { if (width <= maxWidth && height <= maxHeight) {
return imageDataUrl; // No compression needed return imageDataUrl;
} }
if (width / height > maxWidth / maxHeight) { if (width / height > maxWidth / maxHeight) {
@@ -342,19 +396,19 @@ class FileAttachmentService {
} }
} else if (maxWidth != null) { } else if (maxWidth != null) {
if (width <= maxWidth) { if (width <= maxWidth) {
return imageDataUrl; // No compression needed return imageDataUrl;
} }
height = ((maxWidth * height) / width).round(); height = ((maxWidth * height) / width).round();
width = maxWidth; width = maxWidth;
} else if (maxHeight != null) { } else if (maxHeight != null) {
if (height <= maxHeight) { if (height <= maxHeight) {
return imageDataUrl; // No compression needed return imageDataUrl;
} }
width = ((maxHeight * width) / height).round(); width = ((maxHeight * width) / height).round();
height = maxHeight; height = maxHeight;
} }
// Create compressed image // Create resized image (dart:ui only supports PNG output)
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder); final canvas = Canvas(recorder);
@@ -366,22 +420,28 @@ class FileAttachmentService {
); );
final picture = recorder.endRecording(); final picture = recorder.endRecording();
final compressedImage = await picture.toImage(width, height); final resizedImage = await picture.toImage(width, height);
final byteData = await compressedImage.toByteData( final byteData = await resizedImage.toByteData(
format: ui.ImageByteFormat.png, format: ui.ImageByteFormat.png,
); );
final compressedBytes = byteData!.buffer.asUint8List(); final pngBytes = byteData!.buffer.asUint8List();
// Convert back to data URL // Convert PNG to WebP for better compression
final compressedBase64 = base64Encode(compressedBytes); final webpBytes = await FlutterImageCompress.compressWithList(
return 'data:image/png;base64,$compressedBase64'; pngBytes,
format: CompressFormat.webp,
quality: 85,
);
final compressedBase64 = base64Encode(webpBytes);
return 'data:image/webp;base64,$compressedBase64';
} catch (e) { } catch (e) {
DebugLogger.error( DebugLogger.error(
'compress-failed', 'compress-failed',
scope: 'attachments/image', scope: 'attachments/image',
error: e, error: e,
); );
return imageDataUrl; // Return original if compression fails return imageDataUrl;
} }
} }

View File

@@ -593,7 +593,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
Widget _buildSegmentedContent() { Widget _buildSegmentedContent() {
final children = <Widget>[]; final children = <Widget>[];
bool firstToolSpacerAdded = false; bool firstToolSpacerAdded = false;
bool hasNonTextSegment = false;
int idx = 0; int idx = 0;
for (final seg in _segments) { for (final seg in _segments) {
if (seg.isTool && seg.toolCall != null) { if (seg.isTool && seg.toolCall != null) {
@@ -603,16 +602,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
firstToolSpacerAdded = true; firstToolSpacerAdded = true;
} }
children.add(_buildToolCallTile(seg.toolCall!)); children.add(_buildToolCallTile(seg.toolCall!));
hasNonTextSegment = true;
} else if (seg.isReasoning && seg.reasoning != null) { } else if (seg.isReasoning && seg.reasoning != null) {
children.add(_buildReasoningTile(seg.reasoning!, idx)); children.add(_buildReasoningTile(seg.reasoning!, idx));
hasNonTextSegment = true;
} else if ((seg.text ?? '').trim().isNotEmpty) { } else if ((seg.text ?? '').trim().isNotEmpty) {
// Add spacing before text content if it follows non-text segments // No extra spacing needed - reasoning/tool tiles have bottom padding
if (hasNonTextSegment) {
children.add(const SizedBox(height: Spacing.sm));
hasNonTextSegment = false;
}
children.add(_buildEnhancedMarkdownContent(seg.text!)); children.add(_buildEnhancedMarkdownContent(seg.text!));
} }
idx++; idx++;
@@ -704,12 +697,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return false; 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) .where((status) => status.hidden != true)
.isNotEmpty; .toList();
if (hasVisibleStatus) { final hasPendingStatus = visibleStatuses.any((status) => status.done != true);
if (hasPendingStatus) {
// Pending status has shimmer effect, no need for typing indicator
return false; return false;
} }
// If all statuses are done but no content yet, show typing indicator
final hasFollowUps = widget.message.followUps.isNotEmpty; final hasFollowUps = widget.message.followUps.isNotEmpty;
if (hasFollowUps) { if (hasFollowUps) {

View File

@@ -142,7 +142,7 @@ class CitationBadge extends StatelessWidget {
SourceHelper.launchSourceUrl(url); SourceHelper.launchSourceUrl(url);
} }
}, },
borderRadius: BorderRadius.circular(AppBorderRadius.chip), borderRadius: BorderRadius.circular(20),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm, horizontal: Spacing.sm,
@@ -150,33 +150,22 @@ class CitationBadge extends StatelessWidget {
), ),
margin: const EdgeInsets.symmetric(horizontal: 2), margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.surfaceContainer.withValues(alpha: 0.6), color: theme.surfaceContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppBorderRadius.chip), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: theme.cardBorder.withValues(alpha: 0.5), color: theme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.thin, width: 1,
), ),
), ),
child: Row( child: Text(
mainAxisSize: MainAxisSize.min, displayTitle,
children: [ style: TextStyle(
Icon( fontSize: AppTypography.labelSmall,
Icons.link_rounded, fontWeight: FontWeight.w500,
size: 10, color: theme.textSecondary,
color: theme.textSecondary.withValues(alpha: 0.7), ),
), maxLines: 1,
const SizedBox(width: Spacing.xxs), overflow: TextOverflow.ellipsis,
Text(
displayTitle,
style: TextStyle(
fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w500,
color: theme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
), ),