From da63e3cbff937b2125d6c31a06e834ac1edcdac4 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:40:38 +0530 Subject: [PATCH] feat(attachments): Optimize file ID extraction and image conversion --- lib/core/services/api_service.dart | 17 +- lib/core/services/conversation_parsing.dart | 5 +- .../chat/providers/chat_providers.dart | 94 ++++++- .../services/file_attachment_service.dart | 245 +++--------------- .../widgets/assistant_message_widget.dart | 9 +- .../chat/widgets/user_message_bubble.dart | 14 +- lib/shared/services/tasks/task_worker.dart | 190 ++++++++------ 7 files changed, 266 insertions(+), 308 deletions(-) diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 61799bc..dc06354 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2709,15 +2709,20 @@ class ApiService { final processedMessages = messages.map((message) { final role = message['role'] as String; final content = message['content']; - final files = message['files'] as List>?; + // Safely cast files list - may be List from spread operations + final rawFiles = message['files']; + final files = rawFiles is List + ? rawFiles.whereType>().toList() + : >[]; final isContentArray = content is List; - final hasImages = files?.any((file) => file['type'] == 'image') ?? false; + final hasImages = + files.isNotEmpty && files.any((file) => file['type'] == 'image'); if (isContentArray) { return {'role': role, 'content': content}; } else if (hasImages && role == 'user') { - final imageFiles = files! + final imageFiles = files .where((file) => file['type'] == 'image') .toList(); final contentText = content is String ? content : ''; @@ -2741,8 +2746,10 @@ class ApiService { // Separate files from messages final allFiles = >[]; for (final message in messages) { - final files = message['files'] as List>?; - if (files != null) { + // Safely cast files list - may be List from spread operations + final rawFiles = message['files']; + if (rawFiles is List) { + final files = rawFiles.whereType>().toList(); final nonImageFiles = files .where((file) => file['type'] != 'image') .toList(); diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index d97f1f3..52f2990 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -279,7 +279,10 @@ Map _parseOpenWebUIMessageToJson( allFiles.add(fileMap); final url = entry['url'].toString(); - final match = RegExp(r'/api/v1/files/([^/]+)/content').firstMatch(url); + // Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content + final match = RegExp( + r'/api/v1/files/([^/]+)(?:/content)?$', + ).firstMatch(url); if (match != null) { attachments.add(match.group(1)!); } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 7719b27..205035f 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1116,7 +1116,7 @@ Future _getFileAsBase64(dynamic api, String fileId) async { // Small internal helper to convert a message with attachments into the // OpenWebUI content payload format (text + image_url + files). // - Adds text first (if non-empty) -// - Converts image attachments to image_url with data URLs (resolving MIME type when needed) +// - Handles images as inline base64 data URLs (matching web client behavior) // - Includes non-image attachments in a 'files' array for server-side resolution Future> _buildMessagePayloadWithAttachments({ required dynamic api, @@ -1135,13 +1135,25 @@ Future> _buildMessagePayloadWithAttachments({ for (final attachmentId in attachmentIds) { try { + // Check if this is an image data URL (stored locally, matching web client) + // Web client stores images as base64 data URLs, not server file IDs + if (attachmentId.startsWith('data:image/')) { + // This is an inline image data URL - add directly to content array + contentArray.add({ + 'type': 'image_url', + 'image_url': {'url': attachmentId}, + }); + continue; + } + + // For server-stored files, fetch info final fileInfo = await api.getFileInfo(attachmentId); final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown'; final fileSize = fileInfo['size']; final base64Data = await _getFileAsBase64(api, attachmentId); if (base64Data != null) { - // This is an image file - add to content array only + // This is an image file from server - add to content array only if (base64Data.startsWith('data:')) { contentArray.add({ 'type': 'image_url', @@ -1170,11 +1182,11 @@ Future> _buildMessagePayloadWithAttachments({ // Note: Images are handled in content array above, no need to duplicate in files array // This prevents duplicate display in the WebUI } else { - // This is a non-image file + // This is a non-image file - match web client format allFiles.add({ 'type': 'file', 'id': attachmentId, // Required for RAG system to lookup file content - 'url': '/api/v1/files/$attachmentId/content', + 'url': '/api/v1/files/$attachmentId', 'name': fileName, if (fileSize != null) 'size': fileSize, }); @@ -1799,14 +1811,67 @@ Future _sendMessageInternal( var activeConversation = ref.read(activeConversationProvider); // Create user message first - // Note: We only store context attachments (web/youtube/knowledge) in msg.files. - // Uploaded files are tracked via attachmentIds and will be rebuilt by - // _buildMessagePayloadWithAttachments when constructing the API payload. - // This prevents uploaded files from being duplicated in the final message. + // Build the files array to match web client format for persistence: + // - Images stored as {type: 'image', url: 'data:...'} (matching web client) + // - Server files stored as {type: 'file', id: '...', name: '...', url: '...'} + // - Context attachments (web/youtube/knowledge) final contextAttachments = ref.read(contextAttachmentsProvider); final contextFiles = _contextAttachmentsToFiles(contextAttachments); - final List>? userFiles = contextFiles.isNotEmpty - ? contextFiles + + // Convert attachments to files format for web client compatibility + final attachmentFiles = >[]; + if (attachments != null && !reviewerMode && api != null) { + for (final attachment in attachments) { + // Data URLs are images - store inline + 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', + }); + } + } + } + } else if (attachments != null) { + // Reviewer mode or no API - only handle images (server files need API) + for (final attachment in attachments) { + if (attachment.startsWith('data:image/')) { + attachmentFiles.add({'type': 'image', 'url': attachment}); + } else { + DebugLogger.log( + 'Ignoring non-image attachment in reviewer mode: $attachment', + scope: 'chat/providers', + ); + } + } + } + + // Combine attachment files and context files + final List>? userFiles = + (attachmentFiles.isNotEmpty || contextFiles.isNotEmpty) + ? [...attachmentFiles, ...contextFiles] : null; final userMessage = ChatMessage( @@ -1969,8 +2034,13 @@ Future _sendMessageInternal( attachmentIds: ids, ); if (msg.files != null && msg.files!.isNotEmpty) { - messageMap['files'] = [ - ...?messageMap['files'] as List?, + // Safe cast - messageMap['files'] may be List after storage + final rawFiles = messageMap['files']; + final existingFiles = rawFiles is List + ? rawFiles.whereType>().toList() + : >[]; + messageMap['files'] = >[ + ...existingFiles, ...msg.files!, ]; } diff --git a/lib/features/chat/services/file_attachment_service.dart b/lib/features/chat/services/file_attachment_service.dart index 88f811c..625ffb5 100644 --- a/lib/features/chat/services/file_attachment_service.dart +++ b/lib/features/chat/services/file_attachment_service.dart @@ -6,10 +6,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart' as path; -import '../../../core/services/api_service.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/utils/debug_logger.dart'; +/// Converts an image file to a base64 data URL. +/// This is a standalone utility used by both FileAttachmentService and TaskWorker. +/// Returns null if conversion fails. +Future convertImageFileToDataUrl(File imageFile) async { + try { + final bytes = await imageFile.readAsBytes(); + final ext = path.extension(imageFile.path).toLowerCase(); + + 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)}'; + } catch (e) { + DebugLogger.error('convert-image-failed', scope: 'attachments', error: e); + return null; + } +} + String _deriveDisplayName({ required String? preferredName, required String filePath, @@ -73,10 +96,9 @@ class LocalAttachment { } class FileAttachmentService { - final ApiService _apiService; final ImagePicker _imagePicker = ImagePicker(); - FileAttachmentService(this._apiService); + FileAttachmentService(); // Pick files from device Future> pickFiles({ @@ -269,139 +291,23 @@ class FileAttachmentService { } } - // Convert image file to base64 data URL with compression + // Convert image file to base64 data URL with optional compression Future convertImageToDataUrl( File imageFile, { bool enableCompression = false, int? maxWidth, int? maxHeight, }) async { - try { - DebugLogger.log( - 'convert-start', - scope: 'attachments/image', - data: {'path': imageFile.path}, - ); + // Use the shared utility for basic conversion + String? dataUrl = await convertImageFileToDataUrl(imageFile); + if (dataUrl == null) return null; - // Read the file as bytes - final bytes = await imageFile.readAsBytes(); - - // Determine MIME type based on file extension - final ext = path.extension(imageFile.path).toLowerCase(); - String mimeType = 'image/png'; // default - - if (ext == '.jpg' || ext == '.jpeg') { - mimeType = 'image/jpeg'; - } else if (ext == '.gif') { - mimeType = 'image/gif'; - } else if (ext == '.webp') { - mimeType = 'image/webp'; - } - - // Convert to base64 - final base64String = base64Encode(bytes); - String dataUrl = 'data:$mimeType;base64,$base64String'; - - // Apply compression if enabled - if (enableCompression && (maxWidth != null || maxHeight != null)) { - dataUrl = await compressImage(dataUrl, maxWidth, maxHeight); - } - - DebugLogger.log( - 'convert-done', - scope: 'attachments/image', - data: {'mime': mimeType}, - ); - return dataUrl; - } catch (e) { - DebugLogger.error('convert-failed', scope: 'attachments/image', error: e); - return null; + // Apply compression if enabled + if (enableCompression && (maxWidth != null || maxHeight != null)) { + dataUrl = await compressImage(dataUrl, maxWidth, maxHeight); } - } - // Upload file with progress tracking - Stream uploadFile(LocalAttachment attachment) async* { - DebugLogger.log( - 'upload-start', - scope: 'attachments/file', - data: { - 'path': attachment.file.path, - 'displayName': attachment.displayName, - }, - ); - try { - final file = attachment.file; - final fileName = attachment.displayName; - final fileSize = await file.length(); - final ext = path.extension(fileName).toLowerCase(); - final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext); - - DebugLogger.log( - 'file-details', - scope: 'attachments/file', - data: {'name': fileName, 'bytes': fileSize}, - ); - - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: 0.0, - status: FileUploadStatus.uploading, - isImage: isImage, - ); - - // Upload ALL files (including images) to server for consistency with web client - DebugLogger.log('upload-progress', scope: 'attachments/file'); - final fileId = await _apiService.uploadFile(file.path, fileName); - DebugLogger.log( - 'upload-complete', - scope: 'attachments/file', - data: {'fileId': fileId}, - ); - - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: 1.0, - status: FileUploadStatus.completed, - fileId: fileId, - isImage: isImage, - ); - } catch (e) { - DebugLogger.error('upload-failed', scope: 'attachments/file', error: e); - final file = attachment.file; - final fileName = attachment.displayName; - final fileSize = await file.length(); - final ext = path.extension(fileName).toLowerCase(); - final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext); - - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: 0.0, - status: FileUploadStatus.failed, - error: e.toString(), - isImage: isImage, - ); - } - } - - // Upload multiple files - Stream> uploadMultipleFiles( - List attachments, - ) async* { - final states = {}; - - for (final attachment in attachments) { - final uploadStream = uploadFile(attachment); - await for (final state in uploadStream) { - states[attachment.file.path] = state; - yield states.values.toList(); - } - } + return dataUrl; } // Format file size for display @@ -452,7 +358,11 @@ class FileUploadState { final FileUploadStatus status; final String? fileId; final String? error; - final bool? isImage; // Added for image files + final bool? isImage; + + /// For images: stores the base64 data URL (e.g., "data:image/png;base64,...") + /// This matches web client behavior where images are not uploaded to server. + final String? base64DataUrl; FileUploadState({ required this.file, @@ -462,7 +372,8 @@ class FileUploadState { required this.status, this.fileId, this.error, - this.isImage, // Added for image files + this.isImage, + this.base64DataUrl, }); String get formattedSize { @@ -578,78 +489,6 @@ class MockFileAttachmentService { throw Exception('Failed to take photo: $e'); } } - - // Mock upload file with progress tracking - Stream uploadFile(LocalAttachment attachment) async* { - DebugLogger.log( - 'mock-upload', - scope: 'attachments/mock', - data: { - 'path': attachment.file.path, - 'displayName': attachment.displayName, - }, - ); - - final file = attachment.file; - final fileName = attachment.displayName; - final fileSize = await file.length(); - - // Yield initial state - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: 0.0, - status: FileUploadStatus.uploading, - isImage: attachment.isImage, - ); - - // Simulate upload progress - for (int i = 1; i <= 10; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: i / 10, - status: FileUploadStatus.uploading, - isImage: attachment.isImage, - ); - } - - // Yield completed state with mock file ID - yield FileUploadState( - file: file, - fileName: fileName, - fileSize: fileSize, - progress: 1.0, - status: FileUploadStatus.completed, - fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}', - isImage: attachment.isImage, - ); - - DebugLogger.log('mock-complete', scope: 'attachments/mock'); - } - - Future> uploadFiles( - List attachments, { - Function(int, int)? onProgress, - required String conversationId, - }) async { - final uploadIds = []; - - for (int i = 0; i < attachments.length; i++) { - if (onProgress != null) { - for (int j = 0; j <= 100; j += 10) { - await Future.delayed(const Duration(milliseconds: 50)); - onProgress(i, j); - } - } - uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i'); - } - - return uploadIds; - } } // Providers @@ -660,9 +499,11 @@ final fileAttachmentServiceProvider = Provider((ref) { return MockFileAttachmentService(); } + // Guard: only provide service when user is logged in final apiService = ref.watch(apiServiceProvider); if (apiService == null) return null; - return FileAttachmentService(apiService); + + return FileAttachmentService(); }); // State notifier for managing attached files diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index e593075..d682b2f 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -30,7 +30,8 @@ import 'streaming_status_widget.dart'; // Pre-compiled regex patterns for image processing (performance optimization) final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); -final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)/content'); +// Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content +final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)(?:/content)?$'); class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -1116,10 +1117,10 @@ class _AssistantMessageWidgetState extends ConsumerState if (fileUrl == null) return const SizedBox.shrink(); - // Extract file ID from URL if it's in the format /api/v1/files/{id}/content + // Extract file ID from URL - handle both formats: + // /api/v1/files/{id} and /api/v1/files/{id}/content String attachmentId = fileUrl; - if (fileUrl.contains('/api/v1/files/') && - fileUrl.contains('/content')) { + if (fileUrl.contains('/api/v1/files/')) { final fileIdMatch = _fileIdPattern.firstMatch(fileUrl); if (fileIdMatch != null) { attachmentId = fileIdMatch.group(1)!; diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 3848265..126196d 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -14,6 +14,10 @@ import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../tools/providers/tools_providers.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 +final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)(?:/content)?$'); + class UserMessageBubble extends ConsumerStatefulWidget { final dynamic message; final bool isUser; @@ -377,13 +381,11 @@ class _UserMessageBubbleState extends ConsumerState { if (fileUrl == null) return const SizedBox.shrink(); - // Extract file ID from URL if it's in the format /api/v1/files/{id}/content + // Extract file ID from URL - handle both formats: + // /api/v1/files/{id} and /api/v1/files/{id}/content String attachmentId = fileUrl; - if (fileUrl.contains('/api/v1/files/') && - fileUrl.contains('/content')) { - final fileIdMatch = RegExp( - r'/api/v1/files/([^/]+)/content', - ).firstMatch(fileUrl); + if (fileUrl.contains('/api/v1/files/')) { + final fileIdMatch = _fileIdPattern.firstMatch(fileUrl); if (fileIdMatch != null) { attachmentId = fileIdMatch.group(1)!; } diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index 573f590..7a34aee 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; @@ -74,8 +74,19 @@ class TaskWorker { } Future _performUploadMedia(UploadMediaTask task) async { + const imageExts = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}; + final lowerName = task.fileName.toLowerCase(); + final bool isImage = imageExts.any(lowerName.endsWith); + + // For images: read as base64 locally (matching web client behavior) + // Web client never uploads images to /api/v1/files/ + if (isImage) { + await _handleImageAsBase64(task); + return; + } + + // For non-images: upload to server final uploader = AttachmentUploadQueue(); - // Ensure queue initialized with API upload callback try { final api = _ref.read(apiServiceProvider); if (api != null) { @@ -83,7 +94,6 @@ class TaskWorker { } } catch (_) {} - // Enqueue and then wait until the item reaches a terminal state for basic parity final id = await uploader.enqueue( filePath: task.filePath, fileName: task.fileName, @@ -103,7 +113,6 @@ class TaskWorker { } if (entry == null) return; - // Reflect progress into UI attachment state if that file is present try { final current = _ref.read(attachedFilesProvider); final idx = current.indexWhere((f) => f.file.path == task.filePath); @@ -116,10 +125,6 @@ class TaskWorker { QueuedAttachmentStatus.failed => FileUploadStatus.failed, QueuedAttachmentStatus.cancelled => FileUploadStatus.failed, }; - const imageExts = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}; - final lowerName = task.fileName.toLowerCase(); - final bool isImage = - existing.isImage ?? imageExts.any(lowerName.endsWith); final newState = FileUploadState( file: File(task.filePath), fileName: task.fileName, @@ -130,7 +135,7 @@ class TaskWorker { status: status, fileId: entry.fileId ?? existing.fileId, error: entry.lastError, - isImage: isImage, + isImage: false, ); _ref .read(attachedFilesProvider.notifier) @@ -149,7 +154,6 @@ class TaskWorker { } }); - // Fire a process tick unawaited(uploader.processQueue()); await completer.future.timeout( const Duration(minutes: 2), @@ -163,6 +167,69 @@ class TaskWorker { ); } + /// Handles image files by reading as base64 locally (matching web client) + Future _handleImageAsBase64(UploadMediaTask task) async { + try { + final file = File(task.filePath); + final base64DataUrl = await convertImageFileToDataUrl(file); + + if (base64DataUrl == null) { + throw Exception('Failed to convert image to base64'); + } + + // Update attachment state with base64 data URL + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final newState = FileUploadState( + file: file, + fileName: task.fileName, + fileSize: task.fileSize ?? existing.fileSize, + progress: 1.0, + status: FileUploadStatus.completed, + fileId: base64DataUrl, + isImage: true, + base64DataUrl: base64DataUrl, + ); + _ref + .read(attachedFilesProvider.notifier) + .updateFileState(task.filePath, newState); + } + + DebugLogger.log( + 'image-base64-complete', + scope: 'tasks/upload', + data: { + 'fileName': task.fileName, + 'dataUrlLength': base64DataUrl.length, + }, + ); + } catch (e) { + DebugLogger.error('image-base64-failed', scope: 'tasks/upload', error: e); + // Update state to failed + try { + final current = _ref.read(attachedFilesProvider); + final idx = current.indexWhere((f) => f.file.path == task.filePath); + if (idx != -1) { + final existing = current[idx]; + final newState = FileUploadState( + file: File(task.filePath), + fileName: task.fileName, + fileSize: task.fileSize ?? existing.fileSize, + progress: 0.0, + status: FileUploadStatus.failed, + error: e.toString(), + isImage: true, + ); + _ref + .read(attachedFilesProvider.notifier) + .updateFileState(task.filePath, newState); + } + } catch (_) {} + } + } + Future _performExecuteToolCall(ExecuteToolCallTask task) async { // Resolve API + selected model final api = _ref.read(apiServiceProvider); @@ -253,102 +320,69 @@ class TaskWorker { } Future _performImageToDataUrl(ImageToDataUrlTask task) async { - // Upload images to server instead of converting to data URLs - final uploader = AttachmentUploadQueue(); + // Convert image to base64 data URL locally (matching web client behavior) try { - final api = _ref.read(apiServiceProvider); - if (api != null) { - await uploader.initialize(onUpload: (p, n) => api.uploadFile(p, n)); - } - } catch (_) {} + final file = File(task.filePath); + final base64DataUrl = await convertImageFileToDataUrl(file); - try { + if (base64DataUrl == null) { + throw Exception('Failed to convert image to base64'); + } + + // Update attachment state with base64 data URL final current = _ref.read(attachedFilesProvider); final idx = current.indexWhere((f) => f.file.path == task.filePath); if (idx != -1) { final existing = current[idx]; - final uploading = FileUploadState( - file: existing.file, + final newState = FileUploadState( + file: file, fileName: task.fileName, fileSize: existing.fileSize, - progress: 0.0, - status: FileUploadStatus.uploading, - fileId: existing.fileId, - isImage: existing.isImage ?? true, + progress: 1.0, + status: FileUploadStatus.completed, + fileId: base64DataUrl, + isImage: true, + base64DataUrl: base64DataUrl, ); _ref .read(attachedFilesProvider.notifier) - .updateFileState(task.filePath, uploading); + .updateFileState(task.filePath, newState); } - } catch (_) {} - final id = await uploader.enqueue( - filePath: task.filePath, - fileName: task.fileName, - fileSize: File(task.filePath).lengthSync(), - ); - - final completer = Completer(); - late final StreamSubscription> sub; - sub = uploader.queueStream.listen((items) { - QueuedAttachment? entry; - try { - entry = items.firstWhere((e) => e.id == id); - } catch (_) { - entry = null; - } - if (entry == null) return; + DebugLogger.log( + 'image-to-dataurl-complete', + scope: 'tasks/image', + data: { + 'fileName': task.fileName, + 'dataUrlLength': base64DataUrl.length, + }, + ); + } catch (e) { + DebugLogger.error( + 'image-to-dataurl-failed', + scope: 'tasks/image', + error: e, + ); + // Update state to failed try { final current = _ref.read(attachedFilesProvider); final idx = current.indexWhere((f) => f.file.path == task.filePath); if (idx != -1) { final existing = current[idx]; - final status = switch (entry.status) { - QueuedAttachmentStatus.pending => FileUploadStatus.uploading, - QueuedAttachmentStatus.uploading => FileUploadStatus.uploading, - QueuedAttachmentStatus.completed => FileUploadStatus.completed, - QueuedAttachmentStatus.failed => FileUploadStatus.failed, - QueuedAttachmentStatus.cancelled => FileUploadStatus.failed, - }; final newState = FileUploadState( file: File(task.filePath), fileName: task.fileName, fileSize: existing.fileSize, - progress: status == FileUploadStatus.completed - ? 1.0 - : existing.progress, - status: status, - fileId: entry.fileId ?? existing.fileId, + progress: 0.0, + status: FileUploadStatus.failed, + error: e.toString(), isImage: true, - error: entry.lastError, ); _ref .read(attachedFilesProvider.notifier) .updateFileState(task.filePath, newState); } } catch (_) {} - switch (entry.status) { - case QueuedAttachmentStatus.completed: - case QueuedAttachmentStatus.failed: - case QueuedAttachmentStatus.cancelled: - sub.cancel(); - completer.complete(); - break; - default: - break; - } - }); - - unawaited(uploader.processQueue()); - await completer.future.timeout( - const Duration(minutes: 2), - onTimeout: () { - try { - sub.cancel(); - } catch (_) {} - DebugLogger.warning('Image upload timed out: ${task.fileName}'); - return; - }, - ); + } } }