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);
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
try {
|
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(
|
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||||
conversationId: activeConv?.id,
|
conversationId: activeConv?.id,
|
||||||
filePath: file.path,
|
filePath: file.path,
|
||||||
fileName: path.basename(file.path),
|
fileName: path.basename(file.path),
|
||||||
fileSize: await file.length(),
|
fileSize: await file.length(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import '../widgets/file_attachment_widget.dart';
|
|||||||
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
||||||
import '../services/voice_input_service.dart';
|
import '../services/voice_input_service.dart';
|
||||||
import '../services/file_attachment_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 '../../tools/providers/tools_providers.dart';
|
||||||
import '../../navigation/widgets/chats_drawer.dart';
|
import '../../navigation/widgets/chats_drawer.dart';
|
||||||
import '../../../shared/widgets/offline_indicator.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/sheet_handle.dart';
|
||||||
import '../../../shared/widgets/conduit_components.dart';
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
import '../../../core/services/settings_service.dart';
|
import '../../../core/services/settings_service.dart';
|
||||||
import '../../../shared/services/tasks/task_queue.dart';
|
|
||||||
// Removed unused PlatformUtils import
|
// Removed unused PlatformUtils import
|
||||||
import '../../../core/services/platform_service.dart' as ps;
|
import '../../../core/services/platform_service.dart' as ps;
|
||||||
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
import 'package:flutter/gestures.dart' show DragStartBehavior;
|
||||||
@@ -358,20 +359,30 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
// Add files to the attachment list
|
// Add files to the attachment list
|
||||||
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
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) {
|
for (final file in files) {
|
||||||
final uploadStream = fileService.uploadFile(file);
|
try {
|
||||||
uploadStream.listen(
|
final ext = path.extension(file.path).toLowerCase();
|
||||||
(state) {
|
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||||
ref
|
if (isImage) {
|
||||||
.read(attachedFilesProvider.notifier)
|
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||||
.updateFileState(file.path, state);
|
conversationId: activeConv?.id,
|
||||||
},
|
filePath: file.path,
|
||||||
onError: (error) {
|
fileName: path.basename(file.path),
|
||||||
if (!mounted) return;
|
|
||||||
debugPrint('Upload failed: $error');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
} 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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -428,23 +439,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
ref.read(attachedFilesProvider.notifier).addFiles([image]);
|
||||||
debugPrint('DEBUG: Image added to attachment list');
|
debugPrint('DEBUG: Image added to attachment list');
|
||||||
|
|
||||||
// Start uploading image
|
// Enqueue upload via task queue for unified retry/progress
|
||||||
debugPrint('DEBUG: Starting image upload...');
|
debugPrint('DEBUG: Enqueueing image upload...');
|
||||||
final uploadStream = fileService.uploadFile(image);
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
uploadStream.listen(
|
try {
|
||||||
(state) {
|
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||||
debugPrint(
|
conversationId: activeConv?.id,
|
||||||
'DEBUG: Upload state update - Status: ${state.status}, Progress: ${state.progress}, FileId: ${state.fileId}',
|
filePath: image.path,
|
||||||
);
|
fileName: path.basename(image.path),
|
||||||
ref
|
|
||||||
.read(attachedFilesProvider.notifier)
|
|
||||||
.updateFileState(image.path, state);
|
|
||||||
},
|
|
||||||
onError: (error) {
|
|
||||||
debugPrint('DEBUG: Image upload error: $error');
|
|
||||||
if (!mounted) return;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('DEBUG: Enqueue image upload failed: $e');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('DEBUG: Image attachment error: $e');
|
debugPrint('DEBUG: Image attachment error: $e');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ abstract class OutboundTask with _$OutboundTask {
|
|||||||
String? error,
|
String? error,
|
||||||
}) = GenerateTitleTask;
|
}) = 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) =>
|
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
||||||
_$OutboundTaskFromJson(json);
|
_$OutboundTaskFromJson(json);
|
||||||
|
|
||||||
|
|||||||
@@ -285,4 +285,25 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
|
|||||||
_process();
|
_process();
|
||||||
return id;
|
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 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/providers/app_providers.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/utils/debug_logger.dart';
|
||||||
import '../../../core/models/chat_message.dart';
|
import '../../../core/models/chat_message.dart';
|
||||||
import '../../../features/chat/providers/chat_providers.dart' as chat;
|
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 'package:uuid/uuid.dart';
|
||||||
import 'outbound_task.dart';
|
import 'outbound_task.dart';
|
||||||
|
|
||||||
@@ -20,6 +24,7 @@ class TaskWorker {
|
|||||||
uploadMedia: _performUploadMedia,
|
uploadMedia: _performUploadMedia,
|
||||||
executeToolCall: _performExecuteToolCall,
|
executeToolCall: _performExecuteToolCall,
|
||||||
generateImage: _performGenerateImage,
|
generateImage: _performGenerateImage,
|
||||||
|
imageToDataUrl: _performImageToDataUrl,
|
||||||
saveConversation: _performSaveConversation,
|
saveConversation: _performSaveConversation,
|
||||||
generateTitle: _performGenerateTitle,
|
generateTitle: _performGenerateTitle,
|
||||||
);
|
);
|
||||||
@@ -96,6 +101,34 @@ class TaskWorker {
|
|||||||
entry = null;
|
entry = null;
|
||||||
}
|
}
|
||||||
if (entry == null) return;
|
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) {
|
switch (entry.status) {
|
||||||
case QueuedAttachmentStatus.completed:
|
case QueuedAttachmentStatus.completed:
|
||||||
case QueuedAttachmentStatus.failed:
|
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 {
|
Future<void> _performSaveConversation(SaveConversationTask task) async {
|
||||||
final api = _ref.read(apiServiceProvider);
|
final api = _ref.read(apiServiceProvider);
|
||||||
final messages = _ref.read(chat.chatMessagesProvider);
|
final messages = _ref.read(chat.chatMessagesProvider);
|
||||||
|
|||||||
Reference in New Issue
Block a user