feat(task_worker): Enhance image upload with conversion and pre-caching
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'dart:typed_data';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/attachment_upload_queue.dart';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../features/chat/providers/chat_providers.dart' as chat;
|
||||
import '../../../features/chat/providers/context_attachments_provider.dart';
|
||||
import '../../../features/chat/services/file_attachment_service.dart';
|
||||
import '../../../features/chat/widgets/enhanced_image_attachment.dart';
|
||||
import 'outbound_task.dart';
|
||||
|
||||
class TaskWorker {
|
||||
@@ -77,14 +81,9 @@ class TaskWorker {
|
||||
final lowerName = task.fileName.toLowerCase();
|
||||
final bool isImage = allSupportedImageFormats.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
|
||||
// Upload all files (including images) to server
|
||||
// This mirrors OpenWebUI's approach: images are uploaded to /api/v1/files/
|
||||
// and the server resolves them when sending to LLM
|
||||
final uploader = AttachmentUploadQueue();
|
||||
try {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
@@ -93,15 +92,47 @@ class TaskWorker {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// For images: convert unsupported formats and optimize large JPEG/PNG
|
||||
String uploadPath = task.filePath;
|
||||
String uploadFileName = task.fileName;
|
||||
String? uploadMimeType = task.mimeType;
|
||||
if (isImage) {
|
||||
final shouldConvert = await _shouldConvertImage(lowerName, task.fileSize);
|
||||
if (shouldConvert) {
|
||||
final convertedPath = await _convertImageForUpload(task);
|
||||
if (convertedPath != null) {
|
||||
uploadPath = convertedPath;
|
||||
// Update filename to .webp extension since we converted the format
|
||||
final baseName = task.fileName.contains('.')
|
||||
? task.fileName.substring(0, task.fileName.lastIndexOf('.'))
|
||||
: task.fileName;
|
||||
uploadFileName = '$baseName.webp';
|
||||
uploadMimeType = 'image/webp';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read image bytes before upload for instant display cache
|
||||
Uint8List? imageBytes;
|
||||
if (isImage) {
|
||||
try {
|
||||
imageBytes = await File(uploadPath).readAsBytes();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final id = await uploader.enqueue(
|
||||
filePath: task.filePath,
|
||||
fileName: task.fileName,
|
||||
filePath: uploadPath,
|
||||
fileName: uploadFileName,
|
||||
fileSize: task.fileSize ?? 0,
|
||||
mimeType: task.mimeType,
|
||||
mimeType: uploadMimeType,
|
||||
checksum: task.checksum,
|
||||
);
|
||||
|
||||
final completer = Completer<void>();
|
||||
// Capture values for use in closure
|
||||
final displayFileName = uploadFileName;
|
||||
final cachedBytes = imageBytes;
|
||||
final tempFilePath = uploadPath != task.filePath ? uploadPath : null;
|
||||
late final StreamSubscription<List<QueuedAttachment>> sub;
|
||||
sub = uploader.queueStream.listen((items) {
|
||||
QueuedAttachment? entry;
|
||||
@@ -124,9 +155,17 @@ class TaskWorker {
|
||||
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
||||
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
||||
};
|
||||
|
||||
// Pre-cache image bytes for instant display when upload completes
|
||||
if (status == FileUploadStatus.completed &&
|
||||
entry.fileId != null &&
|
||||
cachedBytes != null) {
|
||||
preCacheImageBytes(entry.fileId!, cachedBytes);
|
||||
}
|
||||
|
||||
final newState = FileUploadState(
|
||||
file: File(task.filePath),
|
||||
fileName: task.fileName,
|
||||
fileName: displayFileName,
|
||||
fileSize: task.fileSize ?? existing.fileSize,
|
||||
progress: status == FileUploadStatus.completed
|
||||
? 1.0
|
||||
@@ -134,7 +173,7 @@ class TaskWorker {
|
||||
status: status,
|
||||
fileId: entry.fileId ?? existing.fileId,
|
||||
error: entry.lastError,
|
||||
isImage: false,
|
||||
isImage: isImage,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
@@ -146,6 +185,12 @@ class TaskWorker {
|
||||
case QueuedAttachmentStatus.failed:
|
||||
case QueuedAttachmentStatus.cancelled:
|
||||
sub.cancel();
|
||||
// Clean up temp file from image conversion
|
||||
if (tempFilePath != null) {
|
||||
try {
|
||||
File(tempFilePath).parent.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
completer.complete();
|
||||
break;
|
||||
default:
|
||||
@@ -160,73 +205,104 @@ class TaskWorker {
|
||||
try {
|
||||
sub.cancel();
|
||||
} catch (_) {}
|
||||
|
||||
// Clean up temp file on timeout
|
||||
if (tempFilePath != null) {
|
||||
try {
|
||||
File(tempFilePath).parent.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Update state to failed on timeout
|
||||
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: displayFileName,
|
||||
fileSize: task.fileSize ?? existing.fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
error: 'Upload timed out',
|
||||
isImage: isImage,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(task.filePath, newState);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
DebugLogger.warning('UploadMediaTask timed out: ${task.fileName}');
|
||||
return;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles image files by reading as base64 locally (matching web client)
|
||||
Future<void> _handleImageAsBase64(UploadMediaTask task) async {
|
||||
/// Check if image should be converted to WebP before upload
|
||||
/// - Always convert: HEIC, RAW formats, BMP (unsupported or inefficient)
|
||||
/// - Optimize if large: JPEG, PNG > 500KB
|
||||
/// - Never convert: WebP (already optimal), GIF (may be animated)
|
||||
Future<bool> _shouldConvertImage(String lowerName, int? fileSize) async {
|
||||
// Always convert these formats (unsupported by some backends or inefficient)
|
||||
const alwaysConvert = {
|
||||
'.heic', '.heif', '.dng', '.raw', '.cr2', '.nef', '.arw', '.orf', '.rw2', '.bmp',
|
||||
};
|
||||
if (alwaysConvert.any(lowerName.endsWith)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Never convert these (already optimal or special format)
|
||||
const neverConvert = {'.webp', '.gif'};
|
||||
if (neverConvert.any(lowerName.endsWith)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optimize large JPEG/PNG (> 500KB)
|
||||
const optimizeThreshold = 500 * 1024; // 500KB
|
||||
const optimizableFormats = {'.jpg', '.jpeg', '.png'};
|
||||
if (optimizableFormats.any(lowerName.endsWith)) {
|
||||
final size = fileSize ?? 0;
|
||||
return size > optimizeThreshold;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Convert image to WebP for upload if needed
|
||||
Future<String?> _convertImageForUpload(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,
|
||||
},
|
||||
final result = await FlutterImageCompress.compressWithFile(
|
||||
file.absolute.path,
|
||||
format: CompressFormat.webp,
|
||||
quality: 85,
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
// Write to temp file for upload
|
||||
final tempDir = await Directory.systemTemp.createTemp('conduit_img_');
|
||||
final tempFile = File('${tempDir.path}/converted.webp');
|
||||
await tempFile.writeAsBytes(result);
|
||||
|
||||
DebugLogger.log(
|
||||
'Converted image for upload',
|
||||
scope: 'tasks/upload',
|
||||
data: {
|
||||
'original': task.filePath,
|
||||
'converted': tempFile.path,
|
||||
'originalSize': await file.length(),
|
||||
'convertedSize': result.length,
|
||||
},
|
||||
);
|
||||
|
||||
return tempFile.path;
|
||||
}
|
||||
} 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 (_) {}
|
||||
DebugLogger.error('image-conversion-failed', scope: 'tasks/upload', error: e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
||||
@@ -322,7 +398,11 @@ class TaskWorker {
|
||||
// Convert image to base64 data URL locally (matching web client behavior)
|
||||
try {
|
||||
final file = File(task.filePath);
|
||||
final base64DataUrl = await convertImageFileToDataUrl(file);
|
||||
final worker = _ref.read(workerManagerProvider);
|
||||
final base64DataUrl = await convertImageFileToDataUrl(
|
||||
file,
|
||||
worker: worker,
|
||||
);
|
||||
|
||||
if (base64DataUrl == null) {
|
||||
throw Exception('Failed to convert image to base64');
|
||||
|
||||
Reference in New Issue
Block a user