From e63c57d1fe097dbe7c64515bbe20bf4cf9971621 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:37:49 +0530 Subject: [PATCH] feat: image generation --- lib/core/providers/app_providers.dart | 31 ++ lib/core/services/api_service.dart | 105 +++-- .../chat/providers/chat_providers.dart | 362 +++++++++++++----- .../tools/widgets/unified_tools_modal.dart | 116 +++++- 4 files changed, 466 insertions(+), 148 deletions(-) diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index be8afb0..1836967 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -896,6 +896,37 @@ final conversationSuggestionsProvider = FutureProvider>(( } }); +// Server features and permissions +final userPermissionsProvider = FutureProvider>(( + ref, +) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return {}; + + try { + return await api.getUserPermissions(); + } catch (e) { + foundation.debugPrint('DEBUG: Error fetching user permissions: $e'); + return {}; + } +}); + +final imageGenerationAvailableProvider = Provider((ref) { + final perms = ref.watch(userPermissionsProvider); + return perms.maybeWhen( + data: (data) { + final features = data['features']; + if (features is Map) { + final value = features['image_generation']; + if (value is bool) return value; + if (value is String) return value.toLowerCase() == 'true'; + } + return false; + }, + orElse: () => false, + ); +}); + // Folders provider final foldersProvider = FutureProvider>((ref) async { final api = ref.watch(apiServiceProvider); diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 521fabc..4c37e87 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -27,7 +27,7 @@ class ApiService { // Public getter for dio instance Dio get dio => _dio; - + // Public getter for base URL String get baseUrl => serverConfig.url; @@ -724,14 +724,14 @@ class ApiService { // Parse attachments and generated images from 'files' field List? attachmentIds; List>? files; - + if (msgData['files'] != null) { final filesList = msgData['files'] as List; - + // Separate user uploads (with file_id) from generated images (with type and url) final userAttachments = []; final generatedFiles = >[]; - + for (final file in filesList) { if (file is Map) { if (file['file_id'] != null) { @@ -739,14 +739,11 @@ class ApiService { userAttachments.add(file['file_id'] as String); } else if (file['type'] == 'image' && file['url'] != null) { // Generated image - generatedFiles.add({ - 'type': file['type'], - 'url': file['url'], - }); + generatedFiles.add({'type': file['type'], 'url': file['url']}); } } } - + attachmentIds = userAttachments.isNotEmpty ? userAttachments : null; files = generatedFiles.isNotEmpty ? generatedFiles : null; } @@ -1688,9 +1685,8 @@ class ApiService { required String text, String? voice, }) async { - debugPrint( - 'DEBUG: Generating speech for text: ${text.substring(0, 50)}...', - ); + final textPreview = text.length > 50 ? text.substring(0, 50) : text; + debugPrint('DEBUG: Generating speech for text: $textPreview...'); final response = await _dio.post( '/api/v1/audio/speech', data: {'text': text, if (voice != null) 'voice': voice}, @@ -1805,7 +1801,7 @@ class ApiService { return []; } - Future> generateImage({ + Future generateImage({ required String prompt, String? model, int? width, @@ -1813,21 +1809,37 @@ class ApiService { int? steps, double? guidance, }) async { - debugPrint( - 'DEBUG: Generating image with prompt: ${prompt.substring(0, 50)}...', - ); - final response = await _dio.post( - '/api/v1/images/generations', - data: { - 'prompt': prompt, - if (model != null) 'model': model, - if (width != null) 'width': width, - if (height != null) 'height': height, - if (steps != null) 'steps': steps, - if (guidance != null) 'guidance': guidance, - }, - ); - return response.data as Map; + final promptPreview = prompt.length > 50 ? prompt.substring(0, 50) : prompt; + debugPrint('DEBUG: Generating image with prompt: $promptPreview...'); + try { + final response = await _dio.post( + '/api/v1/images/generations', + data: { + 'prompt': prompt, + if (model != null) 'model': model, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (steps != null) 'steps': steps, + if (guidance != null) 'guidance': guidance, + }, + ); + return response.data; + } on DioException catch (e) { + debugPrint('DEBUG: images/generations failed: ${e.response?.statusCode}'); + // Fallback to singular path some servers use + final response = await _dio.post( + '/api/v1/image/generations', + data: { + 'prompt': prompt, + if (model != null) 'model': model, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (steps != null) 'steps': steps, + if (guidance != null) 'guidance': guidance, + }, + ); + return response.data; + } } // Prompts @@ -1841,6 +1853,24 @@ class ApiService { return []; } + // Permissions & Features + Future> getUserPermissions() async { + debugPrint('DEBUG: Fetching user permissions'); + try { + final response = await _dio.get('/api/v1/users/permissions'); + return response.data as Map; + } catch (e) { + debugPrint('DEBUG: Error fetching user permissions: $e'); + if (e is DioException) { + debugPrint('DEBUG: Permissions error response: ${e.response?.data}'); + debugPrint( + 'DEBUG: Permissions error status: ${e.response?.statusCode}', + ); + } + rethrow; + } + } + Future> createPrompt({ required String title, required String content, @@ -2434,6 +2464,7 @@ class ApiService { String? conversationId, List? toolIds, bool enableWebSearch = false, + bool enableImageGeneration = false, Map? modelItem, }) { final streamController = StreamController(); @@ -2504,17 +2535,25 @@ class ApiService { data['chat_id'] = conversationId; } - // Add web search flag if enabled + // Add feature flags if enabled if (enableWebSearch) { data['web_search'] = true; - // Also add it in features for compatibility + debugPrint('DEBUG: Web search enabled in SSE request'); + } + if (enableImageGeneration) { + // Mirror web_search behavior for image generation + data['image_generation'] = true; + debugPrint('DEBUG: Image generation enabled in SSE request'); + } + + if (enableWebSearch || enableImageGeneration) { + // Include features map for compatibility data['features'] = { - 'web_search': true, - 'image_generation': false, + 'web_search': enableWebSearch, + 'image_generation': enableImageGeneration, 'code_interpreter': false, 'memory': false, }; - debugPrint('DEBUG: Web search enabled in SSE request'); } // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings) diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index fee8c5c..6df3cae 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -54,7 +54,7 @@ class ChatMessagesNotifier extends StateNotifier> { 'DEBUG: Loading ${next.messages.length} messages for conversation ${next.id}', ); state = next.messages; - + // Update selected model if conversation has a different model _updateModelForConversation(next); } else { @@ -76,39 +76,43 @@ class ChatMessagesNotifier extends StateNotifier> { } Future _updateModelForConversation(Conversation conversation) async { - debugPrint('DEBUG: _updateModelForConversation called for conversation ${conversation.id}'); - + debugPrint( + 'DEBUG: _updateModelForConversation called for conversation ${conversation.id}', + ); + // Check if conversation has a model specified if (conversation.model == null || conversation.model!.isEmpty) { debugPrint('DEBUG: Conversation has no model specified'); return; } - + debugPrint('DEBUG: Conversation model: ${conversation.model}'); final currentSelectedModel = _ref.read(selectedModelProvider); - + // If the conversation's model is different from the currently selected one if (currentSelectedModel?.id != conversation.model) { debugPrint( 'DEBUG: Conversation model (${conversation.model}) differs from selected model (${currentSelectedModel?.id})', ); - + // Get available models to find the matching one try { final models = await _ref.read(modelsProvider.future); debugPrint('DEBUG: Available models count: ${models.length}'); - + if (models.isEmpty) { - debugPrint('DEBUG: No models available, cannot update selected model'); + debugPrint( + 'DEBUG: No models available, cannot update selected model', + ); return; } - + // Look for exact match first - final conversationModel = models.where( - (model) => model.id == conversation.model, - ).firstOrNull; - + final conversationModel = models + .where((model) => model.id == conversation.model) + .firstOrNull; + if (conversationModel != null) { // Update the selected model _ref.read(selectedModelProvider.notifier).state = conversationModel; @@ -163,17 +167,16 @@ class ChatMessagesNotifier extends StateNotifier> { lastMessage.copyWith(content: content), ]; } - - void updateLastMessageWithFunction(ChatMessage Function(ChatMessage) updater) { + + void updateLastMessageWithFunction( + ChatMessage Function(ChatMessage) updater, + ) { if (state.isEmpty) return; final lastMessage = state.last; if (lastMessage.role != 'assistant') return; - state = [ - ...state.sublist(0, state.length - 1), - updater(lastMessage), - ]; + state = [...state.sublist(0, state.length - 1), updater(lastMessage)]; } void appendToLastMessage(String content) { @@ -286,6 +289,9 @@ final availableToolsProvider = StateProvider>((ref) => []); // Web search enabled state for API-based web search final webSearchEnabledProvider = StateProvider((ref) => false); +// Image generation enabled state - behaves like web search +final imageGenerationEnabledProvider = StateProvider((ref) => false); + // Vision capable models provider final visionCapableModelsProvider = StateProvider>((ref) { final selectedModel = ref.watch(selectedModelProvider); @@ -383,8 +389,10 @@ Future regenerateMessage( String userMessageContent, List? attachments, ) async { - debugPrint('DEBUG: regenerateMessage called with content: $userMessageContent'); - + debugPrint( + 'DEBUG: regenerateMessage called with content: $userMessageContent', + ); + final reviewerMode = ref.read(reviewerModeProvider); final api = ref.read(apiServiceProvider); final selectedModel = ref.read(selectedModelProvider); @@ -416,14 +424,14 @@ Future regenerateMessage( final responseText = ReviewerModeService.generateResponse( userMessage: userMessageContent, ); - + // Simulate streaming response final words = responseText.split(' '); for (final word in words) { await Future.delayed(const Duration(milliseconds: 40)); ref.read(chatMessagesProvider.notifier).appendToLastMessage('$word '); } - + ref.read(chatMessagesProvider.notifier).finishStreaming(); await _saveConversationLocally(ref); return; @@ -433,14 +441,15 @@ Future regenerateMessage( try { // Get conversation history for context (excluding the removed assistant message) final List messages = ref.read(chatMessagesProvider); - final List> conversationMessages = >[]; + final List> conversationMessages = + >[]; for (final msg in messages) { if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { // Handle messages with attachments if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) { final List> contentArray = []; - + // Add text content first if (msg.content.isNotEmpty) { contentArray.add({'type': 'text', 'text': msg.content}); @@ -452,10 +461,7 @@ Future regenerateMessage( }); } else { // Regular text message - conversationMessages.add({ - 'role': msg.role, - 'content': msg.content, - }); + conversationMessages.add({'role': msg.role, 'content': msg.content}); } } } @@ -496,7 +502,6 @@ Future regenerateMessage( ref.read(chatMessagesProvider.notifier).finishStreaming(); await _saveConversationLocally(ref); - } catch (e) { debugPrint('DEBUG: Error during message regeneration: $e'); rethrow; @@ -541,11 +546,15 @@ Future _sendMessageInternal( // Check if we need to create a new conversation first var activeConversation = ref.read(activeConversationProvider); - - debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}'); + + debugPrint( + 'DEBUG: Active conversation before send: ${activeConversation?.id}', + ); // Create user message first - debugPrint('DEBUG: Creating user message with attachments: $attachments, tools: $toolIds'); + debugPrint( + 'DEBUG: Creating user message with attachments: $attachments, tools: $toolIds', + ); final userMessage = ChatMessage( id: const Uuid().v4(), role: 'user', @@ -581,25 +590,25 @@ Future _sendMessageInternal( ); final updatedConversation = localConversation.copyWith( id: serverConversation.id, - messages: serverConversation.messages.isNotEmpty - ? serverConversation.messages + messages: serverConversation.messages.isNotEmpty + ? serverConversation.messages : [userMessage], ); ref.read(activeConversationProvider.notifier).state = updatedConversation; activeConversation = updatedConversation; - + // Set messages in the messages provider to keep UI in sync ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).addMessage(userMessage); - + debugPrint( 'DEBUG: Created conversation ${serverConversation.id} on server with first message', ); debugPrint( 'DEBUG: Server conversation ID: ${serverConversation.id}, Title: ${serverConversation.title}', ); - + // Invalidate conversations provider to refresh the list // Adding a small delay to prevent rapid invalidations that could cause duplicates Future.delayed(const Duration(milliseconds: 100), () { @@ -790,14 +799,18 @@ Future _sendMessageInternal( } } - // Check if web search is enabled for API + // Check feature toggles for API final webSearchEnabled = ref.read(webSearchEnabledProvider); - - // Debug log to track web search state + final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider); + + // Debug log to track feature toggle states debugPrint('DEBUG: Web search toggle state: $webSearchEnabled'); + debugPrint('DEBUG: Image generation toggle state: $imageGenerationEnabled'); // Prepare tools list - pass tool IDs directly - final List? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) ? toolIds : null; + final List? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) + ? toolIds + : null; if (toolIdsForApi != null) { debugPrint('DEBUG: Including tool IDs: $toolIdsForApi'); } @@ -934,6 +947,7 @@ Future _sendMessageInternal( conversationId: activeConversation?.id, toolIds: toolIdsForApi, enableWebSearch: webSearchEnabled, + enableImageGeneration: imageGenerationEnabled, modelItem: modelItem, ); @@ -971,7 +985,7 @@ Future _sendMessageInternal( // Create a stream controller for persistent handling final persistentController = StreamController.broadcast(); - + // Register stream with persistent service for app lifecycle handling final persistentService = PersistentStreamingService(); final streamId = persistentService.registerStream( @@ -1001,43 +1015,48 @@ Future _sendMessageInternal( // Track web search status bool isSearching = false; - + final streamSubscription = persistentController.stream.listen( (chunk) { debugPrint('DEBUG: Received stream chunk: "$chunk"'); - + // Check for web search indicators in the stream if (webSearchEnabled && !isSearching) { // Check if this is the start of web search - if (chunk.contains('[SEARCHING]') || - chunk.contains('Searching the web') || + if (chunk.contains('[SEARCHING]') || + chunk.contains('Searching the web') || chunk.contains('web search')) { isSearching = true; // Update the message to show search status - ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction( - (message) => message.copyWith( - content: '🔍 Searching the web...', - metadata: {'webSearchActive': true}, - ), - ); + ref + .read(chatMessagesProvider.notifier) + .updateLastMessageWithFunction( + (message) => message.copyWith( + content: '🔍 Searching the web...', + metadata: {'webSearchActive': true}, + ), + ); return; // Don't append this chunk } } - + // Check if web search is complete - if (isSearching && (chunk.contains('[/SEARCHING]') || - chunk.contains('Search complete'))) { + if (isSearching && + (chunk.contains('[/SEARCHING]') || + chunk.contains('Search complete'))) { isSearching = false; // Clear the search status message - ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction( - (message) => message.copyWith( - content: '', - metadata: {'webSearchActive': false}, - ), - ); + ref + .read(chatMessagesProvider.notifier) + .updateLastMessageWithFunction( + (message) => message.copyWith( + content: '', + metadata: {'webSearchActive': false}, + ), + ); return; // Don't append this chunk } - + // Regular content - append to message if (!chunk.contains('[SEARCHING]') && !chunk.contains('[/SEARCHING]')) { ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk); @@ -1071,7 +1090,7 @@ Future _sendMessageInternal( // For assistant messages, add completion details if (msg.role == 'assistant') { messageMap['model'] = selectedModel.id; - + // Add mock usage data if not available (OpenWebUI expects this) if (msg.usage != null) { messageMap['usage'] = msg.usage; @@ -1110,7 +1129,6 @@ Future _sendMessageInternal( debugPrint( 'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}', ); - } catch (e) { debugPrint('DEBUG: Chat completed notification failed: $e'); debugPrint('DEBUG: Error details: $e'); @@ -1120,7 +1138,7 @@ Future _sendMessageInternal( // Fetch the latest conversation state without waiting for title generation debugPrint('DEBUG: Fetching latest conversation state...'); debugPrint('DEBUG: Current message count: ${messages.length}'); - + try { // Quick fetch to get the current state - no waiting for title generation final updatedConv = await api.getConversation( @@ -1133,11 +1151,18 @@ Future _sendMessageInternal( messages.length <= 2 && updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty; - + // If title is still "New Chat" and this is the first exchange, trigger title generation if (messages.length <= 2 && updatedConv.title == 'New Chat') { - debugPrint('DEBUG: Triggering title generation for conversation ${activeConversation.id}'); - _triggerTitleGeneration(ref, activeConversation.id, formattedMessages, selectedModel.id); + debugPrint( + 'DEBUG: Triggering title generation for conversation ${activeConversation.id}', + ); + _triggerTitleGeneration( + ref, + activeConversation.id, + formattedMessages, + selectedModel.id, + ); } // Always combine current local messages with updated server content @@ -1292,10 +1317,8 @@ Future _sendMessageInternal( } // Streaming already marked as complete when stream ended - debugPrint( - 'DEBUG: Server content replacement completed', - ); - + debugPrint('DEBUG: Server content replacement completed'); + // Start background title check for first message exchanges if (messages.length <= 2 && updatedConv.title == 'New Chat') { debugPrint('DEBUG: Starting background title check...'); @@ -1320,6 +1343,127 @@ Future _sendMessageInternal( await Future.delayed(const Duration(milliseconds: 100)); await _saveConversationToServer(ref); debugPrint('DEBUG: Conversation save completed'); + + // If image generation is enabled, trigger image generation with the user's prompt + if (imageGenerationEnabled) { + try { + debugPrint('DEBUG: Image generation enabled - triggering request'); + final imageResponse = await api.generateImage(prompt: message); + + // Extract image URLs or base64 data URIs from response + List> extractGeneratedFiles(dynamic resp) { + final results = >[]; + + // If it's already a list (e.g., list of URLs or file maps) + 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; + } + + // Common patterns: { data: [ { url }, { b64_json } ] } + 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) { + // Default to PNG for base64 images + results.add({ + 'type': 'image', + 'url': 'data:image/png;base64,$b64', + }); + } + } else if (item is String && item.isNotEmpty) { + // Some servers may return a list of URLs + results.add({'type': 'image', 'url': item}); + } + } + } + + // Alternative patterns + 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', + }); + } + } + } + } + + // Single fields + 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) { + debugPrint( + 'DEBUG: Image generation returned ${generatedFiles.length} file(s)', + ); + + // Attach images to the last assistant message + ref + .read(chatMessagesProvider.notifier) + .updateLastMessageWithFunction((ChatMessage m) { + final currentFiles = m.files ?? >[]; + return m.copyWith( + files: [...currentFiles, ...generatedFiles], + ); + }); + + // Save updated conversation with images + await _saveConversationToServer(ref); + } else { + debugPrint('DEBUG: No images found in generation response'); + } + } catch (e) { + debugPrint('DEBUG: Image generation failed: $e'); + } + } }, onError: (error) { debugPrint('DEBUG: Stream error in chat provider: $error'); @@ -1354,8 +1498,7 @@ Future _sendMessageInternal( final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '''⚠️ **Message Format Error** + content: '''⚠️ **Message Format Error** This might be because: • Image attachment couldn't be processed @@ -1382,8 +1525,7 @@ This might be because: final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '''⚠️ **Server Error** + content: '''⚠️ **Server Error** This usually means: • OpenWebUI server is experiencing issues @@ -1407,8 +1549,7 @@ This usually means: final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', - content: - '''⏱️ **Request Timeout** + content: '''⏱️ **Request Timeout** This might be because: • Server taking too long to respond @@ -1511,19 +1652,23 @@ Future _triggerTitleGeneration( try { final api = ref.read(apiServiceProvider); if (api == null) return; - - debugPrint('DEBUG: Requesting title generation for conversation $conversationId'); - + + debugPrint( + 'DEBUG: Requesting title generation for conversation $conversationId', + ); + // Call the title generation endpoint final generatedTitle = await api.generateTitle( conversationId: conversationId, messages: messages, model: model, ); - - if (generatedTitle != null && generatedTitle.isNotEmpty && generatedTitle != 'New Chat') { + + if (generatedTitle != null && + generatedTitle.isNotEmpty && + generatedTitle != 'New Chat') { debugPrint('DEBUG: Title generated successfully: $generatedTitle'); - + // Update the active conversation with the new title final activeConversation = ref.read(activeConversationProvider); if (activeConversation?.id == conversationId) { @@ -1532,10 +1677,12 @@ Future _triggerTitleGeneration( updatedAt: DateTime.now(), ); ref.read(activeConversationProvider.notifier).state = updated; - + // Save the updated title to the server try { - debugPrint('DEBUG: Saving generated title to server: $generatedTitle'); + debugPrint( + 'DEBUG: Saving generated title to server: $generatedTitle', + ); final currentMessages = ref.read(chatMessagesProvider); await api.updateConversationWithMessages( conversationId, @@ -1547,7 +1694,7 @@ Future _triggerTitleGeneration( } catch (e) { debugPrint('DEBUG: Failed to save title to server: $e'); } - + // Refresh the conversations list ref.invalidate(conversationsProvider); } @@ -1564,22 +1711,27 @@ Future _triggerTitleGeneration( } // Background function to check for title updates without blocking UI -Future _checkForTitleInBackground(dynamic ref, String conversationId) async { +Future _checkForTitleInBackground( + dynamic ref, + String conversationId, +) async { try { final api = ref.read(apiServiceProvider); if (api == null) return; // Wait a bit before first check to give server time to generate await Future.delayed(const Duration(seconds: 3)); - + // Try a few times with increasing delays for (int i = 0; i < 3; i++) { try { final updatedConv = await api.getConversation(conversationId); - + if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) { - debugPrint('DEBUG: Background title update found: ${updatedConv.title}'); - + debugPrint( + 'DEBUG: Background title update found: ${updatedConv.title}', + ); + // Update the active conversation with the new title final activeConversation = ref.read(activeConversationProvider); if (activeConversation?.id == conversationId) { @@ -1588,14 +1740,14 @@ Future _checkForTitleInBackground(dynamic ref, String conversationId) asyn updatedAt: DateTime.now(), ); ref.read(activeConversationProvider.notifier).state = updated; - + // Refresh the conversations list ref.invalidate(conversationsProvider); } - + return; // Title found, stop checking } - + // Wait before next check (3s, 5s, 7s) if (i < 2) { await Future.delayed(Duration(seconds: 2 + (i * 2))); @@ -1605,8 +1757,10 @@ Future _checkForTitleInBackground(dynamic ref, String conversationId) asyn break; // Stop on error } } - - debugPrint('DEBUG: Background title check completed without finding generated title'); + + debugPrint( + 'DEBUG: Background title check completed without finding generated title', + ); } catch (e) { debugPrint('DEBUG: Background title check failed: $e'); } @@ -1646,9 +1800,7 @@ Future _saveConversationToServer(dynamic ref) async { debugPrint( 'DEBUG: Conversation ID being updated: ${activeConversation.id}', ); - debugPrint( - 'DEBUG: Number of messages to save: ${messages.length}', - ); + debugPrint('DEBUG: Number of messages to save: ${messages.length}'); try { await api.updateConversationWithMessages( @@ -1722,15 +1874,17 @@ Future _saveConversationLocally(dynamic ref) async { // Store conversation locally using the storage service's actual methods final conversationsJson = await storage.getString('conversations') ?? '[]'; final List conversations = jsonDecode(conversationsJson); - + // Find and update or add the conversation - final existingIndex = conversations.indexWhere((c) => c['id'] == updatedConversation.id); + final existingIndex = conversations.indexWhere( + (c) => c['id'] == updatedConversation.id, + ); if (existingIndex >= 0) { conversations[existingIndex] = updatedConversation.toJson(); } else { conversations.add(updatedConversation.toJson()); } - + await storage.setString('conversations', jsonEncode(conversations)); ref.read(activeConversationProvider.notifier).state = updatedConversation; ref.invalidate(conversationsProvider); diff --git a/lib/features/tools/widgets/unified_tools_modal.dart b/lib/features/tools/widgets/unified_tools_modal.dart index 0b69059..cf3fb04 100644 --- a/lib/features/tools/widgets/unified_tools_modal.dart +++ b/lib/features/tools/widgets/unified_tools_modal.dart @@ -6,6 +6,7 @@ import 'dart:io' show Platform; import '../../../core/models/tool.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../chat/providers/chat_providers.dart'; +import '../../../core/providers/app_providers.dart'; import '../providers/tools_providers.dart'; class UnifiedToolsModal extends ConsumerStatefulWidget { @@ -19,6 +20,8 @@ class _UnifiedToolsModalState extends ConsumerState { @override Widget build(BuildContext context) { final webSearchEnabled = ref.watch(webSearchEnabledProvider); + final imageGenEnabled = ref.watch(imageGenerationEnabledProvider); + final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); final selectedToolIds = ref.watch(selectedToolIdsProvider); final toolsAsync = ref.watch(toolsListProvider); @@ -60,6 +63,12 @@ class _UnifiedToolsModalState extends ConsumerState { _buildWebSearchToggle(webSearchEnabled), const SizedBox(height: Spacing.md), + // Image Generation Toggle (conditionally shown) + if (imageGenAvailable) ...[ + _buildImageGenerationToggle(imageGenEnabled), + const SizedBox(height: Spacing.md), + ], + // Tools Section toolsAsync.when( data: (tools) { @@ -95,10 +104,15 @@ class _UnifiedToolsModalState extends ConsumerState { ), ), const SizedBox(height: Spacing.sm), - ...tools.map((tool) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: _buildToolCard(tool, selectedToolIds.contains(tool.id)), - )), + ...tools.map( + (tool) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: _buildToolCard( + tool, + selectedToolIds.contains(tool.id), + ), + ), + ), ], ); }, @@ -221,17 +235,95 @@ class _UnifiedToolsModalState extends ConsumerState { ); } + Widget _buildImageGenerationToggle(bool imageGenEnabled) { + return GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + ref.read(imageGenerationEnabledProvider.notifier).state = + !imageGenEnabled; + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: imageGenEnabled + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: imageGenEnabled + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.photo : Icons.image, + size: IconSize.medium, + color: imageGenEnabled + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.strong, + ), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Image Generation', + style: AppTypography.labelStyle.copyWith( + color: imageGenEnabled + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + Text( + imageGenEnabled + ? 'I can generate images from your prompt' + : 'Enable to generate images with your request', + style: AppTypography.captionStyle.copyWith( + color: imageGenEnabled + ? context.conduitTheme.buttonPrimaryText.withValues( + alpha: Alpha.strong, + ) + : context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + Icon( + imageGenEnabled ? Icons.toggle_on : Icons.toggle_off, + size: IconSize.large, + color: imageGenEnabled + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.textSecondary, + ), + ], + ), + ), + ); + } + Widget _buildToolCard(Tool tool, bool isSelected) { return GestureDetector( onTap: () { HapticFeedback.lightImpact(); final currentIds = ref.read(selectedToolIdsProvider); if (isSelected) { - ref.read(selectedToolIdsProvider.notifier).state = - currentIds.where((id) => id != tool.id).toList(); + ref.read(selectedToolIdsProvider.notifier).state = currentIds + .where((id) => id != tool.id) + .toList(); } else { - ref.read(selectedToolIdsProvider.notifier).state = - [...currentIds, tool.id]; + ref.read(selectedToolIdsProvider.notifier).state = [ + ...currentIds, + tool.id, + ]; } }, child: Container( @@ -274,7 +366,7 @@ class _UnifiedToolsModalState extends ConsumerState { fontWeight: FontWeight.w600, ), ), - if (tool.meta?['description'] != null && + if (tool.meta?['description'] != null && tool.meta!['description'].toString().isNotEmpty) Text( tool.meta!['description'].toString(), @@ -306,11 +398,13 @@ class _UnifiedToolsModalState extends ConsumerState { IconData _getToolIcon(Tool tool) { final toolName = tool.name.toLowerCase(); - + if (toolName.contains('image') || toolName.contains('vision')) { return Platform.isIOS ? CupertinoIcons.photo : Icons.image; } else if (toolName.contains('code') || toolName.contains('python')) { - return Platform.isIOS ? CupertinoIcons.chevron_left_slash_chevron_right : Icons.code; + return Platform.isIOS + ? CupertinoIcons.chevron_left_slash_chevron_right + : Icons.code; } else if (toolName.contains('calculator') || toolName.contains('math')) { return Icons.calculate; } else if (toolName.contains('file') || toolName.contains('document')) {