refactor: image uploads
This commit is contained in:
@@ -158,12 +158,22 @@ Future<void> _processPayload(Ref ref, SharedPayload payload) async {
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
try {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
final ext = path.extension(file.path).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
if (isImage) {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
);
|
||||
} else {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import '../widgets/file_attachment_widget.dart';
|
||||
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
||||
import '../services/voice_input_service.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../../shared/services/tasks/task_queue.dart';
|
||||
import '../../tools/providers/tools_providers.dart';
|
||||
import '../../navigation/widgets/chats_drawer.dart';
|
||||
import '../../../shared/widgets/offline_indicator.dart';
|
||||
@@ -33,7 +35,6 @@ import '../../onboarding/views/onboarding_sheet.dart';
|
||||
import '../../../shared/widgets/sheet_handle.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../core/services/settings_service.dart';
|
||||
import '../../../shared/services/tasks/task_queue.dart';
|
||||
// Removed unused PlatformUtils import
|
||||
import '../../../core/services/platform_service.dart' as ps;
|
||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||
@@ -358,20 +359,30 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Add files to the attachment list
|
||||
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
||||
|
||||
// Start uploading files
|
||||
// Enqueue uploads via task queue for unified retry/progress
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
final uploadStream = fileService.uploadFile(file);
|
||||
uploadStream.listen(
|
||||
(state) {
|
||||
ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(file.path, state);
|
||||
},
|
||||
onError: (error) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Upload failed: $error');
|
||||
},
|
||||
);
|
||||
try {
|
||||
final ext = path.extension(file.path).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
if (isImage) {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
);
|
||||
} else {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Enqueue upload failed: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -428,23 +439,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
||||
debugPrint('DEBUG: Image added to attachment list');
|
||||
|
||||
// Start uploading image
|
||||
debugPrint('DEBUG: Starting image upload...');
|
||||
final uploadStream = fileService.uploadFile(image);
|
||||
uploadStream.listen(
|
||||
(state) {
|
||||
debugPrint(
|
||||
'DEBUG: Upload state update - Status: ${state.status}, Progress: ${state.progress}, FileId: ${state.fileId}',
|
||||
);
|
||||
ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(image.path, state);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('DEBUG: Image upload error: $error');
|
||||
if (!mounted) return;
|
||||
},
|
||||
);
|
||||
// Enqueue upload via task queue for unified retry/progress
|
||||
debugPrint('DEBUG: Enqueueing image upload...');
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
try {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: image.path,
|
||||
fileName: path.basename(image.path),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Enqueue image upload failed: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Image attachment error: $e');
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -98,6 +98,20 @@ abstract class OutboundTask with _$OutboundTask {
|
||||
String? error,
|
||||
}) = GenerateTitleTask;
|
||||
|
||||
const factory OutboundTask.imageToDataUrl({
|
||||
required String id,
|
||||
String? conversationId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
@Default(TaskStatus.queued) TaskStatus status,
|
||||
@Default(0) int attempt,
|
||||
String? idempotencyKey,
|
||||
DateTime? enqueuedAt,
|
||||
DateTime? startedAt,
|
||||
DateTime? completedAt,
|
||||
String? error,
|
||||
}) = ImageToDataUrlTask;
|
||||
|
||||
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
||||
_$OutboundTaskFromJson(json);
|
||||
|
||||
|
||||
@@ -285,4 +285,25 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
|
||||
_process();
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<String> enqueueImageToDataUrl({
|
||||
required String? conversationId,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
final id = _uuid.v4();
|
||||
final task = OutboundTask.imageToDataUrl(
|
||||
id: id,
|
||||
conversationId: conversationId,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
idempotencyKey: idempotencyKey,
|
||||
enqueuedAt: DateTime.now(),
|
||||
);
|
||||
state = [...state, task];
|
||||
await _save();
|
||||
_process();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
@@ -7,6 +9,8 @@ import '../../../core/services/attachment_upload_queue.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
import '../../../features/chat/providers/chat_providers.dart' as chat;
|
||||
import '../../../features/chat/services/file_attachment_service.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'outbound_task.dart';
|
||||
|
||||
@@ -20,6 +24,7 @@ class TaskWorker {
|
||||
uploadMedia: _performUploadMedia,
|
||||
executeToolCall: _performExecuteToolCall,
|
||||
generateImage: _performGenerateImage,
|
||||
imageToDataUrl: _performImageToDataUrl,
|
||||
saveConversation: _performSaveConversation,
|
||||
generateTitle: _performGenerateTitle,
|
||||
);
|
||||
@@ -96,6 +101,34 @@ class TaskWorker {
|
||||
entry = null;
|
||||
}
|
||||
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);
|
||||
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: task.fileSize ?? existing.fileSize,
|
||||
progress: status == FileUploadStatus.completed ? 1.0 : existing.progress,
|
||||
status: status,
|
||||
fileId: entry.fileId ?? existing.fileId,
|
||||
error: entry.lastError,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(task.filePath, newState);
|
||||
}
|
||||
} catch (_) {}
|
||||
switch (entry.status) {
|
||||
case QueuedAttachmentStatus.completed:
|
||||
case QueuedAttachmentStatus.failed:
|
||||
@@ -281,6 +314,84 @@ class TaskWorker {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performImageToDataUrl(ImageToDataUrlTask task) async {
|
||||
try {
|
||||
// Update UI to uploading state first
|
||||
try {
|
||||
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,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 0.5,
|
||||
status: FileUploadStatus.uploading,
|
||||
fileId: existing.fileId,
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
uploading,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Read file and convert to data URL
|
||||
final file = File(task.filePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final b64 = base64Encode(bytes);
|
||||
final ext = path.extension(task.fileName).toLowerCase();
|
||||
String mime = 'image/png';
|
||||
if (ext == '.jpg' || ext == '.jpeg') mime = 'image/jpeg';
|
||||
else if (ext == '.gif') mime = 'image/gif';
|
||||
else if (ext == '.webp') mime = 'image/webp';
|
||||
final dataUrl = 'data:$mime;base64,$b64';
|
||||
|
||||
// Mark as completed with data URL as fileId
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final done = FileUploadState(
|
||||
file: existing.file,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: dataUrl,
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
done,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final failed = FileUploadState(
|
||||
file: existing.file,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
fileId: existing.fileId,
|
||||
error: e.toString(),
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
failed,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performSaveConversation(SaveConversationTask task) async {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
final messages = _ref.read(chat.chatMessagesProvider);
|
||||
|
||||
Reference in New Issue
Block a user