From dc166e2347d48f2b29ab7067a3dcf4f06dba53be Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:45:07 +0530 Subject: [PATCH] refactor: ux --- .../chat/providers/chat_providers.dart | 181 +++++++++++++++++- lib/features/chat/views/chat_page.dart | 16 +- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index d120bdd..ef05973 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1048,6 +1048,28 @@ Future _sendMessageInternal( m.copyWith(files: generatedFiles, isStreaming: false), ); await _saveConversationToServer(ref); + + // Trigger title generation for image-only flow + final activeConv = ref.read(activeConversationProvider); + if (activeConv != null) { + // Build minimal formatted messages + final currentMessages = ref.read(chatMessagesProvider); + final List> formattedMessages = []; + for (final msg in currentMessages) { + formattedMessages.add({ + 'id': msg.id, + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + }); + } + _triggerTitleGeneration( + ref, + activeConv.id, + formattedMessages, + selectedModel.id, + ); + } } else { // No images; mark done ref.read(chatMessagesProvider.notifier).finishStreaming(); @@ -2055,11 +2077,15 @@ Future _saveConversationToServer(dynamic ref) async { return; } - // Check if the last message (assistant) has content + // Check if the last assistant message is truly empty (no text and no files) final lastMessage = messages.last; - if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) { + if (lastMessage.role == 'assistant' && + lastMessage.content.trim().isEmpty && + (lastMessage.files == null || lastMessage.files!.isEmpty) && + (lastMessage.attachmentIds == null || + lastMessage.attachmentIds!.isEmpty)) { debugPrint( - 'DEBUG: Skipping conversation save - assistant message has no content', + 'DEBUG: Skipping conversation save - assistant message has no content or files', ); return; } @@ -2294,6 +2320,14 @@ final regenerateLastMessageProvider = Provider((ref) { // Find last user message with proper bounds checking ChatMessage? lastUserMessage; + // Detect if last assistant message had generated images + final ChatMessage? lastAssistantMessage = messages.isNotEmpty + ? messages.last + : null; + final bool lastAssistantHadImages = + lastAssistantMessage != null && + lastAssistantMessage.role == 'assistant' && + (lastAssistantMessage.files?.any((f) => f['type'] == 'image') == true); for (int i = messages.length - 2; i >= 0 && i < messages.length; i--) { if (i >= 0 && messages[i].role == 'user') { lastUserMessage = messages[i]; @@ -2306,7 +2340,146 @@ final regenerateLastMessageProvider = Provider((ref) { // Remove last assistant message ref.read(chatMessagesProvider.notifier).removeLastMessage(); - // Resend the message + // If previous assistant was image-only or had images, regenerate images instead of text + if (lastAssistantHadImages) { + final api = ref.read(apiServiceProvider); + final selectedModel = ref.read(selectedModelProvider); + if (api == null || selectedModel == null) return; + + // Add assistant placeholder + final placeholder = ChatMessage( + id: const Uuid().v4(), + role: 'assistant', + content: '', + timestamp: DateTime.now(), + model: selectedModel.name, + isStreaming: true, + ); + ref.read(chatMessagesProvider.notifier).addMessage(placeholder); + + try { + debugPrint( + 'DEBUG: Regenerate image-only - triggering image generation', + ); + final imageResponse = await api.generateImage( + prompt: lastUserMessage.content, + ); + + 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; + } + + final generatedFiles = extractGeneratedFiles(imageResponse); + if (generatedFiles.isNotEmpty) { + ref + .read(chatMessagesProvider.notifier) + .updateLastMessageWithFunction( + (ChatMessage m) => + m.copyWith(files: generatedFiles, isStreaming: false), + ); + await _saveConversationToServer(ref); + + // Trigger title generation after image-only regenerate + final activeConv = ref.read(activeConversationProvider); + if (activeConv != null) { + final currentMsgs = ref.read(chatMessagesProvider); + final List> formatted = []; + for (final msg in currentMsgs) { + formatted.add({ + 'id': msg.id, + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + }); + } + _triggerTitleGeneration( + ref, + activeConv.id, + formatted, + selectedModel.id, + ); + } + } else { + ref.read(chatMessagesProvider.notifier).finishStreaming(); + } + } catch (e) { + debugPrint('DEBUG: Regenerate image-only failed: $e'); + ref.read(chatMessagesProvider.notifier).finishStreaming(); + } + return; + } + + // Resend the message via normal flow await _sendMessageInternal( ref, lastUserMessage.content, diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 05a5dee..25c702c 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -885,7 +885,8 @@ class _ChatPageState extends ConsumerState { Spacing.lg, Spacing.lg, ), - physics: const NeverScrollableScrollPhysics(), // Prevent scrolling during load + physics: + const NeverScrollableScrollPhysics(), // Prevent scrolling during load itemCount: 6, itemBuilder: (context, index) { final isUser = index.isOdd; @@ -1691,11 +1692,14 @@ class _ChatPageState extends ConsumerState { return; } - // Check if the last message (assistant) has content - final lastMessage = messages.last; - if (lastMessage.role == 'assistant' && - lastMessage.content.trim().isEmpty) { - // Remove empty assistant message before saving + // Remove trailing assistant message only if it has no text and no files + final lastMessage = messages.isNotEmpty ? messages.last : null; + if (lastMessage != null && + lastMessage.role == 'assistant' && + lastMessage.content.trim().isEmpty && + (lastMessage.files == null || lastMessage.files!.isEmpty) && + (lastMessage.attachmentIds == null || + lastMessage.attachmentIds!.isEmpty)) { messages.removeLast(); if (messages.isEmpty) return; }