diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index c498031..21c64c9 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -2513,12 +2513,13 @@ final regenerateLastMessageProvider = Provider Function()>((ref) { return; } - // Resend the message via normal flow - await _sendMessageInternal( - ref, - lastUserMessage.content, - lastUserMessage.attachmentIds, - ); + // Resend the message via task queue (unified flow) + final activeConv = ref.read(activeConversationProvider); + await ref.read(taskQueueProvider.notifier).enqueueSendText( + conversationId: activeConv?.id, + text: lastUserMessage.content, + attachments: lastUserMessage.attachmentIds, + ); }; }); diff --git a/lib/shared/services/tasks/outbound_task.dart b/lib/shared/services/tasks/outbound_task.dart index 00264c8..d5d54af 100644 --- a/lib/shared/services/tasks/outbound_task.dart +++ b/lib/shared/services/tasks/outbound_task.dart @@ -61,6 +61,19 @@ abstract class OutboundTask with _$OutboundTask { String? error, }) = ExecuteToolCallTask; + const factory OutboundTask.generateImage({ + required String id, + String? conversationId, + required String prompt, + @Default(TaskStatus.queued) TaskStatus status, + @Default(0) int attempt, + String? idempotencyKey, + DateTime? enqueuedAt, + DateTime? startedAt, + DateTime? completedAt, + String? error, + }) = GenerateImageTask; + factory OutboundTask.fromJson(Map json) => _$OutboundTaskFromJson(json); diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index 8da9cbc..2273e5d 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -130,6 +130,25 @@ class TaskQueueNotifier extends StateNotifier> { _process(); } + Future enqueueGenerateImage({ + required String? conversationId, + required String prompt, + String? idempotencyKey, + }) async { + final id = _uuid.v4(); + final task = OutboundTask.generateImage( + id: id, + conversationId: conversationId, + prompt: prompt, + idempotencyKey: idempotencyKey, + enqueuedAt: DateTime.now(), + ); + state = [...state, task]; + await _save(); + _process(); + return id; + } + Future _process() async { if (_processing) return; _processing = true; diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index ed2d6d6..e1c6ac3 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; 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 'package:uuid/uuid.dart'; import 'outbound_task.dart'; class TaskWorker { @@ -17,6 +19,7 @@ class TaskWorker { sendTextMessage: _performSendText, uploadMedia: _performUploadMedia, executeToolCall: _performExecuteToolCall, + generateImage: _performGenerateImage, ); } @@ -117,4 +120,162 @@ class TaskWorker { // We keep this task type for future local tools or MCP bridges. debugPrint('ExecuteToolCallTask stub: ${task.toolName}'); } + + Future _performGenerateImage(GenerateImageTask task) async { + final api = _ref.read(apiServiceProvider); + final selectedModel = _ref.read(selectedModelProvider); + if (api == null) { + throw Exception('API not available'); + } + + // Add assistant placeholder to show progress + try { + final placeholder = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: '', + timestamp: DateTime.now(), + model: selectedModel?.id, + isStreaming: true, + ); + _ref.read(chat.chatMessagesProvider.notifier).addMessage(placeholder); + } catch (_) {} + + // Generate images + List> _extractGeneratedFiles(dynamic resp) { + final results = >[]; + if (resp is List) { + for (final item in resp) { + if (item is String && item.isNotEmpty) { + results.add({'type': 'image', 'url': item}); + } else if (item is Map) { + final url = item['url']; + final b64 = item['b64_json'] ?? item['b64']; + if (url is String && url.isNotEmpty) { + results.add({'type': 'image', 'url': url}); + } else if (b64 is String && b64.isNotEmpty) { + results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'}); + } + } + } + return results; + } + if (resp is! Map) return results; + final data = resp['data']; + if (data is List) { + for (final item in data) { + if (item is Map) { + final url = item['url']; + final b64 = item['b64_json'] ?? item['b64']; + if (url is String && url.isNotEmpty) { + results.add({'type': 'image', 'url': url}); + } else if (b64 is String && b64.isNotEmpty) { + results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'}); + } + } else if (item is String && item.isNotEmpty) { + results.add({'type': 'image', 'url': item}); + } + } + } + final images = resp['images']; + if (images is List) { + for (final item in images) { + if (item is String && item.isNotEmpty) { + results.add({'type': 'image', 'url': item}); + } else if (item is Map) { + final url = item['url']; + final b64 = item['b64_json'] ?? item['b64']; + if (url is String && url.isNotEmpty) { + results.add({'type': 'image', 'url': url}); + } else if (b64 is String && b64.isNotEmpty) { + results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'}); + } + } + } + } + final singleUrl = resp['url']; + if (singleUrl is String && singleUrl.isNotEmpty) { + results.add({'type': 'image', 'url': singleUrl}); + } + final singleB64 = resp['b64_json'] ?? resp['b64']; + if (singleB64 is String && singleB64.isNotEmpty) { + results.add({'type': 'image', 'url': 'data:image/png;base64,$singleB64'}); + } + return results; + } + + try { + final imageResponse = await api.generateImage(prompt: task.prompt); + final generatedFiles = _extractGeneratedFiles(imageResponse); + if (generatedFiles.isNotEmpty) { + _ref.read(chat.chatMessagesProvider.notifier).updateLastMessageWithFunction( + (m) => m.copyWith(files: generatedFiles, isStreaming: false), + ); + + // Sync conversation to server + try { + final messages = _ref.read(chat.chatMessagesProvider); + final activeConv = _ref.read(activeConversationProvider); + if (activeConv != null && messages.isNotEmpty) { + await api.updateConversationWithMessages( + activeConv.id, + messages, + model: selectedModel?.id, + ); + // Update local active conversation messages + final updated = activeConv.copyWith( + messages: messages, + updatedAt: DateTime.now(), + ); + _ref.read(activeConversationProvider.notifier).state = updated; + _ref.invalidate(conversationsProvider); + } + } catch (_) {} + + // Trigger title generation (best-effort) + try { + final activeConv = _ref.read(activeConversationProvider); + final messages = _ref.read(chat.chatMessagesProvider); + final modelId = selectedModel?.id; + if (activeConv != null && modelId != null) { + final formatted = >[]; + for (final msg in messages) { + formatted.add({ + 'id': msg.id, + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + }); + } + final title = await api.generateTitle( + conversationId: activeConv.id, + messages: formatted, + model: modelId, + ); + if (title != null && title.isNotEmpty && title != 'New Chat') { + final updated = activeConv.copyWith( + title: title.length > 100 ? '${title.substring(0, 100)}...' : title, + updatedAt: DateTime.now(), + ); + _ref.read(activeConversationProvider.notifier).state = updated; + try { + final cur = _ref.read(chat.chatMessagesProvider); + await api.updateConversationWithMessages( + updated.id, + cur, + title: updated.title, + model: modelId, + ); + } catch (_) {} + _ref.invalidate(conversationsProvider); + } + } + } catch (_) {} + } else { + _ref.read(chat.chatMessagesProvider.notifier).finishStreaming(); + } + } catch (e) { + _ref.read(chat.chatMessagesProvider.notifier).finishStreaming(); + } + } }