import 'dart:convert'; import 'package:yaml/yaml.dart' as yaml; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../core/utils/tool_calls_parser.dart'; import '../../../core/services/streaming_helper.dart'; import '../../../core/services/socket_service.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/conversation.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/auth/auth_state_manager.dart'; import '../../../core/utils/inactivity_watchdog.dart'; import '../services/reviewer_mode_service.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import 'dart:async'; import '../../../core/utils/debug_logger.dart'; const bool kSocketVerboseLogging = false; // Chat messages for current conversation final chatMessagesProvider = NotifierProvider>( ChatMessagesNotifier.new, ); // Loading state for conversation (used to show chat skeletons during fetch) final isLoadingConversationProvider = NotifierProvider( IsLoadingConversationNotifier.new, ); // Prefilled input text (e.g., when sharing text from other apps) final prefilledInputTextProvider = NotifierProvider( PrefilledInputTextNotifier.new, ); // Trigger to request focus on the chat input (increment to signal) final inputFocusTriggerProvider = NotifierProvider( InputFocusTriggerNotifier.new, ); // Whether the chat composer currently has focus final composerHasFocusProvider = NotifierProvider( ComposerFocusNotifier.new, ); class IsLoadingConversationNotifier extends Notifier { @override bool build() => false; void set(bool value) => state = value; } class PrefilledInputTextNotifier extends Notifier { @override String? build() => null; void set(String? value) => state = value; void clear() => state = null; } class InputFocusTriggerNotifier extends Notifier { @override int build() => 0; void set(int value) => state = value; int increment() { final next = state + 1; state = next; return next; } } class ComposerFocusNotifier extends Notifier { @override bool build() => false; void set(bool value) => state = value; } class ChatMessagesNotifier extends Notifier> { StreamSubscription? _messageStream; ProviderSubscription? _conversationListener; final List _subscriptions = []; final List _socketSubscriptions = []; VoidCallback? _socketTeardown; // Activity-based watchdog to prevent stuck typing indicator InactivityWatchdog? _typingWatchdog; DateTime? _lastStreamingActivity; bool _initialized = false; @override List build() { if (!_initialized) { _initialized = true; _conversationListener = ref.listen(activeConversationProvider, ( previous, next, ) { DebugLogger.log( 'Conversation changed: ${previous?.id} -> ${next?.id}', scope: 'chat/providers', ); // Only react when the conversation actually changes if (previous?.id == next?.id) { // If same conversation but server updated it (e.g., title/content), avoid overwriting // locally streamed assistant content with an outdated server copy. if (previous?.updatedAt != next?.updatedAt) { final serverMessages = next?.messages ?? const []; // Primary rule: adopt server messages when there are strictly more of them. if (serverMessages.length > state.length) { state = serverMessages; return; } // Secondary rule: if counts are equal but the last assistant message grew, // adopt the server copy to recover from missed socket events. if (serverMessages.isNotEmpty && state.isNotEmpty) { final serverLast = serverMessages.last; final localLast = state.last; final serverText = serverLast.content.trim(); final localText = localLast.content.trim(); final sameLastId = serverLast.id == localLast.id; final isAssistant = serverLast.role == 'assistant'; final serverHasMore = serverText.isNotEmpty && serverText.length > localText.length; final localEmptyButServerHas = localText.isEmpty && serverText.isNotEmpty; if (sameLastId && isAssistant && (serverHasMore || localEmptyButServerHas)) { state = serverMessages; return; } } } return; } // Cancel any existing message stream when switching conversations _cancelMessageStream(); // Also cancel typing guard on conversation switch _cancelTypingGuard(); if (next != null) { state = next.messages; // Update selected model if conversation has a different model _updateModelForConversation(next); } else { state = []; } }); ref.onDispose(() { for (final subscription in _subscriptions) { subscription.cancel(); } _subscriptions.clear(); _cancelMessageStream(); cancelSocketSubscriptions(); _cancelTypingGuard(); _conversationListener?.close(); _conversationListener = null; }); } final activeConversation = ref.read(activeConversationProvider); return activeConversation?.messages ?? const []; } void _addSubscription(StreamSubscription subscription) { _subscriptions.add(subscription); } void _cancelMessageStream() { _messageStream?.cancel(); _messageStream = null; cancelSocketSubscriptions(); } void _cancelTypingGuard() { _typingWatchdog?.stop(); _typingWatchdog = null; } void _scheduleTypingGuard({Duration? timeout}) { // Default timeout tuned to balance long tool gaps and UX final effectiveTimeout = timeout ?? const Duration(seconds: 25); _typingWatchdog ??= InactivityWatchdog( window: effectiveTimeout, onTimeout: () async { try { if (state.isEmpty) return; final last = state.last; // Still the same streaming message and no finish signal if (last.role == 'assistant' && last.isStreaming) { // Attempt a soft recovery: if content is still empty, try fetching final content from server if ((last.content).trim().isEmpty) { try { final apiSvc = ref.read(apiServiceProvider); final activeConv = ref.read(activeConversationProvider); final msgId = last.id; final chatId = activeConv?.id; if (apiSvc != null && chatId != null && chatId.isNotEmpty) { final resp = await apiSvc.dio.get('/api/v1/chats/$chatId'); final data = resp.data as Map; String content = ''; final chatObj = data['chat'] as Map?; if (chatObj != null) { final list = chatObj['messages']; if (list is List) { final target = list.firstWhere( (m) => (m is Map && (m['id']?.toString() == msgId)), orElse: () => null, ); if (target != null) { final rawContent = (target as Map)['content']; if (rawContent is String) { content = rawContent; } else if (rawContent is List) { final textItem = rawContent.firstWhere( (i) => i is Map && i['type'] == 'text', orElse: () => null, ); if (textItem != null) { content = (textItem as Map)['text']?.toString() ?? ''; } } } } if (content.isEmpty) { final history = chatObj['history']; if (history is Map && history['messages'] is Map) { final Map messagesMap = (history['messages'] as Map) .cast(); final msg = messagesMap[msgId]; if (msg is Map) { final rawContent = msg['content']; if (rawContent is String) { content = rawContent; } else if (rawContent is List) { final textItem = rawContent.firstWhere( (i) => i is Map && i['type'] == 'text', orElse: () => null, ); if (textItem != null) { content = (textItem as Map)['text']?.toString() ?? ''; } } } } } } if (content.isNotEmpty) { replaceLastMessageContent(content); } } } catch (_) {} } // Regardless of fetch result, ensure UI is not stuck finishStreaming(); } } finally { _cancelTypingGuard(); } }, ); _typingWatchdog!.setWindow(effectiveTimeout); _typingWatchdog!.ping(); } void _touchStreamingActivity() { _lastStreamingActivity = DateTime.now(); // Keep guard alive while streaming if (state.isNotEmpty) { final last = state.last; if (last.role == 'assistant' && last.isStreaming) { // Compute a dynamic timeout based on flow type Duration timeout = const Duration(seconds: 25); try { final meta = last.metadata ?? const {}; final isBgFlow = (meta['backgroundFlow'] == true); final isWebSearchFlow = (meta['webSearchFlow'] == true) || (meta['webSearchActive'] == true); final isImageGenFlow = (meta['imageGenerationFlow'] == true); // Also consult global toggles if metadata not present final globalWebSearch = ref.read(webSearchEnabledProvider); final webSearchAvailable = ref.read(webSearchAvailableProvider); final globalImageGen = ref.read(imageGenerationEnabledProvider); // Extend guard windows to tolerate long reasoning/tools (> 1 min) if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) { if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60); } if (isBgFlow) { // Background tools/dynamic channel can be much longer if (timeout.inSeconds < 120) timeout = const Duration(seconds: 120); } if (isImageGenFlow || globalImageGen) { // Image generation tends to be the longest if (timeout.inSeconds < 180) timeout = const Duration(seconds: 180); } } catch (_) {} _scheduleTypingGuard(timeout: timeout); } } } // Enhanced streaming recovery method similar to OpenWebUI's approach void recoverStreamingIfNeeded() { if (state.isEmpty) return; final lastMessage = state.last; if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return; // Check if streaming has been inactive for too long final now = DateTime.now(); if (_lastStreamingActivity != null) { final inactiveTime = now.difference(_lastStreamingActivity!); // If inactive for more than 3 minutes, consider recovery if (inactiveTime > const Duration(minutes: 3)) { DebugLogger.log( 'Streaming inactive for ${inactiveTime.inSeconds}s, attempting recovery', scope: 'chat/provider', ); // Try to gracefully finish the streaming state finishStreaming(); } } } // Public wrapper to cancel the currently active stream (used by Stop) void cancelActiveMessageStream() { _cancelMessageStream(); } Future _updateModelForConversation(Conversation conversation) async { // Check if conversation has a model specified if (conversation.model == null || conversation.model!.isEmpty) { return; } final currentSelectedModel = ref.read(selectedModelProvider); // If the conversation's model is different from the currently selected one if (currentSelectedModel?.id != conversation.model) { // Get available models to find the matching one try { final models = await ref.read(modelsProvider.future); if (models.isEmpty) { return; } // Look for exact match first final conversationModel = models .where((model) => model.id == conversation.model) .firstOrNull; if (conversationModel != null) { // Update the selected model ref.read(selectedModelProvider.notifier).set(conversationModel); } else { // Model not found in available models - silently continue } } catch (e) { // Model update failed - silently continue } } } void setMessageStream(StreamSubscription stream) { _cancelMessageStream(); _messageStream = stream; // Add to tracked subscriptions for comprehensive cleanup _addSubscription(stream); } void setSocketSubscriptions( List subscriptions, { VoidCallback? onDispose, }) { cancelSocketSubscriptions(); _socketSubscriptions.addAll(subscriptions); _socketTeardown = onDispose; } void cancelSocketSubscriptions() { if (_socketSubscriptions.isEmpty) { _socketTeardown?.call(); _socketTeardown = null; return; } for (final sub in _socketSubscriptions) { try { sub.dispose(); } catch (_) {} } _socketSubscriptions.clear(); _socketTeardown?.call(); _socketTeardown = null; } void addMessage(ChatMessage message) { state = [...state, message]; if (message.role == 'assistant' && message.isStreaming) { _touchStreamingActivity(); } } void removeLastMessage() { if (state.isNotEmpty) { state = state.sublist(0, state.length - 1); } } void clearMessages() { state = []; } void setMessages(List messages) { state = messages; } void updateLastMessage(String content) { if (state.isEmpty) return; final lastMessage = state.last; if (lastMessage.role != 'assistant') return; // Ensure we never keep the typing placeholder in persisted content String sanitized(String s) { const ti = '[TYPING_INDICATOR]'; const searchBanner = '🔍 Searching the web...'; if (s.startsWith(ti)) { s = s.substring(ti.length); } if (s.startsWith(searchBanner)) { s = s.substring(searchBanner.length); } return s; } state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(content: sanitized(content)), ]; _touchStreamingActivity(); } void updateLastMessageWithFunction( ChatMessage Function(ChatMessage) updater, ) { if (state.isEmpty) return; final lastMessage = state.last; if (lastMessage.role != 'assistant') return; final updated = updater(lastMessage); state = [...state.sublist(0, state.length - 1), updated]; if (updated.isStreaming) { _touchStreamingActivity(); } } void updateMessageById( String messageId, ChatMessage Function(ChatMessage current) updater, ) { final index = state.indexWhere((m) => m.id == messageId); if (index == -1) return; final original = state[index]; final updated = updater(original); if (identical(updated, original)) { return; } final next = [...state]; next[index] = updated; state = next; } void appendStatusUpdate(String messageId, ChatStatusUpdate update) { updateMessageById(messageId, (current) { final history = [...current.statusHistory, update]; return current.copyWith(statusHistory: history); }); } void setFollowUps(String messageId, List followUps) { updateMessageById(messageId, (current) { return current.copyWith(followUps: List.from(followUps)); }); } void upsertCodeExecution(String messageId, ChatCodeExecution execution) { updateMessageById(messageId, (current) { final existing = current.codeExecutions; final idx = existing.indexWhere((e) => e.id == execution.id); if (idx == -1) { return current.copyWith(codeExecutions: [...existing, execution]); } final next = [...existing]; next[idx] = execution; return current.copyWith(codeExecutions: next); }); } void appendSourceReference(String messageId, ChatSourceReference reference) { updateMessageById(messageId, (current) { final existing = current.sources; final alreadyPresent = existing.any((source) { if (reference.id != null && reference.id!.isNotEmpty) { return source.id == reference.id; } if (reference.url != null && reference.url!.isNotEmpty) { return source.url == reference.url; } return false; }); if (alreadyPresent) { return current; } return current.copyWith(sources: [...existing, reference]); }); } void appendToLastMessage(String content) { if (state.isEmpty) { return; } final lastMessage = state.last; if (lastMessage.role != 'assistant') { return; } if (!lastMessage.isStreaming) { // Ignore late chunks when streaming already finished return; } // Strip a leading typing indicator if present, then append delta const ti = '[TYPING_INDICATOR]'; const searchBanner = '🔍 Searching the web...'; String current = lastMessage.content; if (current.startsWith(ti)) { current = current.substring(ti.length); } if (current.startsWith(searchBanner)) { current = current.substring(searchBanner.length); } final newContent = current.isEmpty ? content : current + content; state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(content: newContent), ]; _touchStreamingActivity(); } void replaceLastMessageContent(String content) { if (state.isEmpty) { return; } final lastMessage = state.last; if (lastMessage.role != 'assistant') { return; } // Remove typing indicator if present in the replacement String sanitized = content; const ti = '[TYPING_INDICATOR]'; const searchBanner = '🔍 Searching the web...'; if (sanitized.startsWith(ti)) { sanitized = sanitized.substring(ti.length); } if (sanitized.startsWith(searchBanner)) { sanitized = sanitized.substring(searchBanner.length); } state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(content: sanitized), ]; _touchStreamingActivity(); } void finishStreaming() { if (state.isEmpty) return; final lastMessage = state.last; if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return; // Also strip any leftover typing indicator before finalizing const ti = '[TYPING_INDICATOR]'; const searchBanner = '🔍 Searching the web...'; String cleaned = lastMessage.content; if (cleaned.startsWith(ti)) { cleaned = cleaned.substring(ti.length); } if (cleaned.startsWith(searchBanner)) { cleaned = cleaned.substring(searchBanner.length); } state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(isStreaming: false, content: cleaned), ]; _cancelTypingGuard(); // Trigger a refresh of the conversations list so UI like the Chats Drawer // can pick up updated titles and ordering once streaming completes. // Best-effort: ignore if ref lifecycle/context prevents invalidation. try { ref.invalidate(conversationsProvider); } catch (_) {} } } // Pre-seed an assistant skeleton message (with a given id or a new one), // persist it to the server to keep the chain correct, and return the id. Future _preseedAssistantAndPersist( dynamic ref, { String? existingAssistantId, required String modelId, String? systemPrompt, }) async { // Choose id: reuse existing if provided, else create new final String assistantMessageId = (existingAssistantId != null && existingAssistantId.isNotEmpty) ? existingAssistantId : const Uuid().v4(); // If the message with this id doesn't exist locally, add a placeholder final msgs = ref.read(chatMessagesProvider); final exists = msgs.any((m) => m.id == assistantMessageId); if (!exists) { final placeholder = ChatMessage( id: assistantMessageId, role: 'assistant', content: '', timestamp: DateTime.now(), model: modelId, isStreaming: true, ); ref.read(chatMessagesProvider.notifier).addMessage(placeholder); } else { // If it exists and is the last assistant, ensure we mark it streaming try { final last = msgs.isNotEmpty ? msgs.last : null; if (last != null && last.id == assistantMessageId && last.role == 'assistant' && !last.isStreaming) { ref .read(chatMessagesProvider.notifier) .updateLastMessageWithFunction( (m) => m.copyWith(isStreaming: true), ); } } catch (_) {} } // Sync conversation state to ensure WebUI can load conversation history try { final api = ref.read(apiServiceProvider); final activeConv = ref.read(activeConversationProvider); if (api != null && activeConv != null) { final resolvedSystemPrompt = (systemPrompt != null && systemPrompt.trim().isNotEmpty) ? systemPrompt.trim() : activeConv.systemPrompt; final current = ref.read(chatMessagesProvider); await api.syncConversationMessages( activeConv.id, current, model: modelId, systemPrompt: resolvedSystemPrompt, ); } } catch (_) { // Non-critical - continue if sync fails } return assistantMessageId; } String? _extractSystemPromptFromSettings(Map? settings) { if (settings == null) return null; final rootValue = settings['system']; if (rootValue is String) { final trimmed = rootValue.trim(); if (trimmed.isNotEmpty) return trimmed; } final ui = settings['ui']; if (ui is Map) { final uiValue = ui['system']; if (uiValue is String) { final trimmed = uiValue.trim(); if (trimmed.isNotEmpty) return trimmed; } } return null; } // Start a new chat (unified function for both "New Chat" button and home screen) void startNewChat(dynamic ref) { // Clear active conversation ref.read(activeConversationProvider.notifier).clear(); // Clear messages ref.read(chatMessagesProvider.notifier).clearMessages(); } // Available tools provider final availableToolsProvider = NotifierProvider>( AvailableToolsNotifier.new, ); // Web search enabled state for API-based web search final webSearchEnabledProvider = NotifierProvider( WebSearchEnabledNotifier.new, ); // Image generation enabled state - behaves like web search final imageGenerationEnabledProvider = NotifierProvider( ImageGenerationEnabledNotifier.new, ); // Vision capable models provider final visionCapableModelsProvider = NotifierProvider>( VisionCapableModelsNotifier.new, ); // File upload capable models provider final fileUploadCapableModelsProvider = NotifierProvider>( FileUploadCapableModelsNotifier.new, ); class AvailableToolsNotifier extends Notifier> { @override List build() => []; void set(List tools) => state = List.from(tools); } class WebSearchEnabledNotifier extends Notifier { @override bool build() => false; void set(bool value) => state = value; } class ImageGenerationEnabledNotifier extends Notifier { @override bool build() => false; void set(bool value) => state = value; } class VisionCapableModelsNotifier extends Notifier> { @override List build() { final selectedModel = ref.watch(selectedModelProvider); if (selectedModel == null) { return []; } if (selectedModel.isMultimodal == true) { return [selectedModel.id]; } // For now, assume all models support vision unless explicitly marked return [selectedModel.id]; } } class FileUploadCapableModelsNotifier extends Notifier> { @override List build() { final selectedModel = ref.watch(selectedModelProvider); if (selectedModel == null) { return []; } // For now, assume all models support file upload return [selectedModel.id]; } } // Helper function to validate file size bool validateFileSize(int fileSize, int? maxSizeMB) { if (maxSizeMB == null) return true; final maxSizeBytes = maxSizeMB * 1024 * 1024; return fileSize <= maxSizeBytes; } // Helper function to validate file count bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) { if (maxCount == null) return true; return (currentCount + newFilesCount) <= maxCount; } // Helper function to build files array from attachment IDs Future>?> _buildFilesArrayFromAttachments( dynamic api, List attachmentIds, ) async { final filesArray = >[]; for (final attachmentId in attachmentIds) { try { final fileInfo = await api.getFileInfo(attachmentId); final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown'; final fileSize = fileInfo['size']; // Check if it's an image final ext = fileName.toLowerCase().split('.').last; final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); // Add all files to the files array for WebUI display // Note: This is for storage/display, not for API message sending filesArray.add({ 'type': isImage ? 'image' : 'file', 'id': attachmentId, // Required for RAG system to lookup file content 'url': '/api/v1/files/$attachmentId/content', 'name': fileName, if (fileSize != null) 'size': fileSize, }); } catch (_) { // If we can't get file info, assume it's a non-image file // Images should be handled in the content array anyway filesArray.add({ 'type': 'file', 'id': attachmentId, // Required for RAG system to lookup file content 'url': '/api/v1/files/$attachmentId/content', 'name': 'Unknown', }); } } return filesArray.isNotEmpty ? filesArray : null; } // Helper function to get file content as base64 Future _getFileAsBase64(dynamic api, String fileId) async { // Check if this is already a data URL (for images) if (fileId.startsWith('data:')) { return fileId; } try { // First, get file info to determine if it's an image final fileInfo = await api.getFileInfo(fileId); // Try different fields for filename - check all possible field names final fileName = fileInfo['filename'] ?? fileInfo['meta']?['name'] ?? fileInfo['name'] ?? fileInfo['file_name'] ?? fileInfo['original_name'] ?? fileInfo['original_filename'] ?? ''; final ext = fileName.toLowerCase().split('.').last; // Only process image files if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) { return null; } // Get file content as base64 string final fileContent = await api.getFileContent(fileId); // The API service returns base64 string directly return fileContent; } catch (e) { return null; } } // Small internal helper to convert a message with attachments into the // OpenWebUI content payload format (text + image_url + files). // - Adds text first (if non-empty) // - Converts image attachments to image_url with data URLs (resolving MIME type when needed) // - Includes non-image attachments in a 'files' array for server-side resolution Future> _buildMessagePayloadWithAttachments({ required dynamic api, required String role, required String cleanedText, required List attachmentIds, }) async { final List> contentArray = []; if (cleanedText.isNotEmpty) { contentArray.add({'type': 'text', 'text': cleanedText}); } // Collect all files in OpenWebUI format for the files array final allFiles = >[]; for (final attachmentId in attachmentIds) { try { final fileInfo = await api.getFileInfo(attachmentId); final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown'; final fileSize = fileInfo['size']; final base64Data = await _getFileAsBase64(api, attachmentId); if (base64Data != null) { // This is an image file - add to content array only if (base64Data.startsWith('data:')) { contentArray.add({ 'type': 'image_url', 'image_url': {'url': base64Data}, }); } else { final ext = fileName.toLowerCase().split('.').last; String mimeType = 'image/png'; if (ext == 'jpg' || ext == 'jpeg') { mimeType = 'image/jpeg'; } else if (ext == 'gif') { mimeType = 'image/gif'; } else if (ext == 'webp') { mimeType = 'image/webp'; } final dataUrl = 'data:$mimeType;base64,$base64Data'; contentArray.add({ 'type': 'image_url', 'image_url': {'url': dataUrl}, }); } // Note: Images are handled in content array above, no need to duplicate in files array // This prevents duplicate display in the WebUI } else { // This is a non-image file allFiles.add({ 'type': 'file', 'id': attachmentId, // Required for RAG system to lookup file content 'url': '/api/v1/files/$attachmentId/content', 'name': fileName, if (fileSize != null) 'size': fileSize, }); } } catch (_) { // Swallow and continue to keep regeneration robust } } final messageMap = { 'role': role, 'content': contentArray.isNotEmpty ? contentArray : cleanedText, }; if (allFiles.isNotEmpty) { messageMap['files'] = allFiles; } return messageMap; } // Regenerate message function that doesn't duplicate user message Future regenerateMessage( dynamic ref, String userMessageContent, List? attachments, ) async { final reviewerMode = ref.read(reviewerModeProvider); final api = ref.read(apiServiceProvider); final selectedModel = ref.read(selectedModelProvider); if ((!reviewerMode && api == null) || selectedModel == null) { throw Exception('No API service or model selected'); } var activeConversation = ref.read(activeConversationProvider); if (activeConversation == null) { throw Exception('No active conversation'); } // In reviewer mode, simulate response if (reviewerMode) { final assistantMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '', timestamp: DateTime.now(), model: selectedModel.id, isStreaming: true, ); ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); // Helpers defined above // Reviewer mode: no immediate tool preview (no tool context) // Reviewer mode: no immediate tool preview (no tool context) // Use canned response for regeneration 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; } // For real API, proceed with regeneration using existing conversation messages try { Map? userSettingsData; String? userSystemPrompt; try { userSettingsData = await api!.getUserSettings(); userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData); } catch (_) {} if ((activeConversation.systemPrompt == null || activeConversation.systemPrompt!.trim().isEmpty) && (userSystemPrompt?.isNotEmpty ?? false)) { final updated = activeConversation.copyWith( systemPrompt: userSystemPrompt, ); ref.read(activeConversationProvider.notifier).set(updated); activeConversation = updated; } // Include selected tool ids so provider-native tool calling is triggered final selectedToolIds = ref.read(selectedToolIdsProvider); // Get conversation history for context (excluding the removed assistant message) final List messages = ref.read(chatMessagesProvider); final List> conversationMessages = >[]; for (int i = 0; i < messages.length; i++) { final msg = messages[i]; if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { final cleaned = ToolCallsParser.sanitizeForApi(msg.content); // Prefer provided attachments for the last user message; otherwise use message attachments final bool isLastUser = (i == messages.length - 1) && msg.role == 'user'; final List messageAttachments = (isLastUser && (attachments != null && attachments.isNotEmpty)) ? List.from(attachments) : (msg.attachmentIds ?? const []); if (messageAttachments.isNotEmpty) { final messageMap = await _buildMessagePayloadWithAttachments( api: api, role: msg.role, cleanedText: cleaned, attachmentIds: messageAttachments, ); conversationMessages.add(messageMap); } else { conversationMessages.add({'role': msg.role, 'content': cleaned}); } } } final conversationSystemPrompt = activeConversation.systemPrompt?.trim(); final effectiveSystemPrompt = (conversationSystemPrompt != null && conversationSystemPrompt.isNotEmpty) ? conversationSystemPrompt : userSystemPrompt; if (effectiveSystemPrompt != null && effectiveSystemPrompt.isNotEmpty) { final hasSystemMessage = conversationMessages.any( (m) => (m['role']?.toString().toLowerCase() ?? '') == 'system', ); if (!hasSystemMessage) { conversationMessages.insert(0, { 'role': 'system', 'content': effectiveSystemPrompt, }); } } // Pre-seed assistant skeleton and persist chain final String assistantMessageId = await _preseedAssistantAndPersist( ref, modelId: selectedModel.id, systemPrompt: effectiveSystemPrompt, ); // Feature toggles final webSearchEnabled = ref.read(webSearchEnabledProvider) && ref.read(webSearchAvailableProvider); final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider); // Model metadata for completion notifications final supportedParams = selectedModel.supportedParameters ?? [ 'max_tokens', 'tool_choice', 'tools', 'response_format', 'structured_outputs', ]; final modelItem = { 'id': selectedModel.id, 'canonical_slug': selectedModel.id, 'hugging_face_id': '', 'name': selectedModel.name, 'created': 1754089419, 'description': selectedModel.description ?? 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrouter/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', 'context_length': 256000, 'architecture': { 'modality': 'text+image->text', 'input_modalities': ['image', 'text'], 'output_modalities': ['text'], 'tokenizer': 'Other', 'instruct_type': null, }, 'pricing': { 'prompt': '0', 'completion': '0', 'request': '0', 'image': '0', 'audio': '0', 'web_search': '0', 'internal_reasoning': '0', }, 'top_provider': { 'context_length': 256000, 'max_completion_tokens': 128000, 'is_moderated': false, }, 'per_request_limits': null, 'supported_parameters': supportedParams, 'connection_type': 'external', 'owned_by': 'openai', 'openai': { 'id': selectedModel.id, 'canonical_slug': selectedModel.id, 'hugging_face_id': '', 'name': selectedModel.name, 'created': 1754089419, 'description': selectedModel.description ?? 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrout' 'er/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', 'context_length': 256000, 'architecture': { 'modality': 'text+image->text', 'input_modalities': ['image', 'text'], 'output_modalities': ['text'], 'tokenizer': 'Other', 'instruct_type': null, }, 'pricing': { 'prompt': '0', 'completion': '0', 'request': '0', 'image': '0', 'audio': '0', 'web_search': '0', 'internal_reasoning': '0', }, 'top_provider': { 'context_length': 256000, 'max_completion_tokens': 128000, 'is_moderated': false, }, 'per_request_limits': null, 'supported_parameters': [ 'max_tokens', 'tool_choice', 'tools', 'response_format', 'structured_outputs', ], 'connection_type': 'external', }, 'urlIdx': 0, 'actions': [], 'filters': [], 'tags': [], }; // Socket binding for background flows final socketService = ref.read(socketServiceProvider); String? socketSessionId = socketService?.sessionId; bool wantSessionBinding = (socketService?.isConnected == true) && (socketSessionId != null && socketSessionId.isNotEmpty); // When regenerating with tools, make a best-effort to ensure a live socket. if (!wantSessionBinding && socketService != null) { try { final ok = await socketService.ensureConnected(); if (ok) { socketSessionId = socketService.sessionId; wantSessionBinding = socketSessionId != null && socketSessionId.isNotEmpty; } } catch (_) {} } // Resolve tool servers from user settings (if any) List>? toolServers; final uiSettings = userSettingsData?['ui'] as Map?; final rawServers = uiSettings != null ? (uiSettings['toolServers'] as List?) : null; if (rawServers != null && rawServers.isNotEmpty) { try { toolServers = await _resolveToolServers(rawServers, api); } catch (_) {} } // Background tasks parity with Web client (safe defaults) bool shouldGenerateTitle = false; try { final conv = ref.read(activeConversationProvider); final nonSystemCount = conversationMessages .where((m) => (m['role']?.toString() ?? '') != 'system') .length; shouldGenerateTitle = (conv == null) || ((conv.title == 'New Chat' || (conv.title.isEmpty)) && nonSystemCount == 1); } catch (_) {} final bgTasks = { if (shouldGenerateTitle) 'title_generation': true, if (shouldGenerateTitle) 'tags_generation': true, 'follow_up_generation': true, if (webSearchEnabled) 'web_search': true, if (imageGenerationEnabled) 'image_generation': true, }; final bool isBackgroundToolsFlowPre = (selectedToolIds.isNotEmpty) || (toolServers != null && toolServers.isNotEmpty); final bool isBackgroundWebSearchPre = webSearchEnabled; // Dispatch using unified send pipeline (background tools flow) final bool isBackgroundFlowPre = isBackgroundToolsFlowPre || isBackgroundWebSearchPre || imageGenerationEnabled; final bool passSocketSession = wantSessionBinding && (isBackgroundFlowPre || bgTasks.isNotEmpty); final response = api!.sendMessage( messages: conversationMessages, model: selectedModel.id, conversationId: activeConversation.id, toolIds: selectedToolIds.isNotEmpty ? selectedToolIds : null, enableWebSearch: webSearchEnabled, enableImageGeneration: imageGenerationEnabled, modelItem: modelItem, sessionIdOverride: passSocketSession ? socketSessionId : null, socketSessionId: socketSessionId, toolServers: toolServers, backgroundTasks: bgTasks, responseMessageId: assistantMessageId, ); final stream = response.stream; final sessionId = response.sessionId; final effectiveSessionId = response.socketSessionId ?? socketSessionId ?? sessionId; final bool isBackgroundFlow = response.isBackgroundFlow; try { ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(( m, ) { final mergedMeta = { if (m.metadata != null) ...m.metadata!, 'backgroundFlow': isBackgroundFlow, if (isBackgroundWebSearchPre) 'webSearchFlow': true, if (imageGenerationEnabled) 'imageGenerationFlow': true, }; return m.copyWith(metadata: mergedMeta); }); } catch (_) {} final activeStream = attachUnifiedChunkedStreaming( stream: stream, webSearchEnabled: webSearchEnabled, assistantMessageId: assistantMessageId, modelId: selectedModel.id, modelItem: modelItem, sessionId: effectiveSessionId, activeConversationId: activeConversation.id, api: api, socketService: socketService, appendToLastMessage: (c) => ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), replaceLastMessageContent: (c) => ref.read(chatMessagesProvider.notifier).replaceLastMessageContent(c), updateLastMessageWith: (updater) => ref .read(chatMessagesProvider.notifier) .updateLastMessageWithFunction(updater), appendStatusUpdate: (messageId, update) => ref .read(chatMessagesProvider.notifier) .appendStatusUpdate(messageId, update), setFollowUps: (messageId, followUps) => ref .read(chatMessagesProvider.notifier) .setFollowUps(messageId, followUps), upsertCodeExecution: (messageId, execution) => ref .read(chatMessagesProvider.notifier) .upsertCodeExecution(messageId, execution), appendSourceReference: (messageId, reference) => ref .read(chatMessagesProvider.notifier) .appendSourceReference(messageId, reference), updateMessageById: (messageId, updater) => ref .read(chatMessagesProvider.notifier) .updateMessageById(messageId, updater), onChatTitleUpdated: (newTitle) { final active = ref.read(activeConversationProvider); if (active != null) { ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); } ref.invalidate(conversationsProvider); }, onChatTagsUpdated: () { ref.invalidate(conversationsProvider); final active = ref.read(activeConversationProvider); final api = ref.read(apiServiceProvider); if (active != null && api != null) { Future.microtask(() async { try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); } catch (_) {} }); } }, finishStreaming: () => ref.read(chatMessagesProvider.notifier).finishStreaming(), getMessages: () => ref.read(chatMessagesProvider), ); ref.read(chatMessagesProvider.notifier) ..setMessageStream(activeStream.streamSubscription) ..setSocketSubscriptions( activeStream.socketSubscriptions, onDispose: activeStream.disposeWatchdog, ); return; } catch (e) { rethrow; } } // Send message function for widgets Future sendMessage( WidgetRef ref, String message, List? attachments, [ List? toolIds, ]) async { await _sendMessageInternal(ref, message, attachments, toolIds); } // Service-friendly wrapper (accepts generic Ref) Future sendMessageFromService( Ref ref, String message, List? attachments, [ List? toolIds, ]) async { await _sendMessageInternal(ref, message, attachments, toolIds); } // Internal send message implementation Future _sendMessageInternal( dynamic ref, String message, List? attachments, [ List? toolIds, ]) async { final reviewerMode = ref.read(reviewerModeProvider); final api = ref.read(apiServiceProvider); final selectedModel = ref.read(selectedModelProvider); if ((!reviewerMode && api == null) || selectedModel == null) { throw Exception('No API service or model selected'); } Map? userSettingsData; String? userSystemPrompt; if (!reviewerMode && api != null) { try { userSettingsData = await api.getUserSettings(); userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData); } catch (_) {} } // Check if we need to create a new conversation first var activeConversation = ref.read(activeConversationProvider); // Create user message first List>? userFiles; if (attachments != null && attachments.isNotEmpty && !reviewerMode && api != null) { userFiles = await _buildFilesArrayFromAttachments(api, attachments); } final userMessage = ChatMessage( id: const Uuid().v4(), role: 'user', content: message, timestamp: DateTime.now(), model: selectedModel.id, attachmentIds: attachments, files: userFiles, ); if (activeConversation == null) { // Create new conversation with the first message included final localConversation = Conversation( id: const Uuid().v4(), title: 'New Chat', createdAt: DateTime.now(), updatedAt: DateTime.now(), systemPrompt: userSystemPrompt, messages: [userMessage], // Include the user message ); // Set as active conversation locally ref.read(activeConversationProvider.notifier).set(localConversation); activeConversation = localConversation; if (!reviewerMode) { // Try to create on server with the first message included try { final serverConversation = await api.createConversation( title: 'New Chat', messages: [userMessage], // Include the first message in creation model: selectedModel.id, systemPrompt: userSystemPrompt, ); final updatedConversation = localConversation.copyWith( id: serverConversation.id, systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt, messages: serverConversation.messages.isNotEmpty ? serverConversation.messages : [userMessage], ); ref.read(activeConversationProvider.notifier).set(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); // 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), () { try { // Guard against using ref after widget disposal if (ref.mounted == true) { ref.invalidate(conversationsProvider); } } catch (_) { // If ref doesn't support mounted or is disposed, skip } }); } catch (e) { // Still add the message locally ref.read(chatMessagesProvider.notifier).addMessage(userMessage); } } else { // Add message for reviewer mode ref.read(chatMessagesProvider.notifier).addMessage(userMessage); } } else { // Add user message to existing conversation ref.read(chatMessagesProvider.notifier).addMessage(userMessage); } if (activeConversation != null && (activeConversation.systemPrompt == null || activeConversation.systemPrompt!.trim().isEmpty) && (userSystemPrompt?.isNotEmpty ?? false)) { final updated = activeConversation.copyWith(systemPrompt: userSystemPrompt); ref.read(activeConversationProvider.notifier).set(updated); activeConversation = updated; } // We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode) // Reviewer mode: simulate a response locally and return if (reviewerMode) { // Add assistant message placeholder final assistantMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '', timestamp: DateTime.now(), model: selectedModel.id, isStreaming: true, ); ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); // Check if there are attachments String? filename; if (attachments != null && attachments.isNotEmpty) { // Get the first attachment filename for the response // In reviewer mode, we just simulate having a file filename = "demo_file.txt"; } // Check if this is voice input // In reviewer mode, we don't have actual voice input state final isVoiceInput = false; // Generate appropriate canned response final responseText = ReviewerModeService.generateResponse( userMessage: message, filename: filename, isVoiceInput: isVoiceInput, ); // Simulate token-by-token streaming 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(); // Save locally await _saveConversationLocally(ref); return; } // Get conversation history for context final List messages = ref.read(chatMessagesProvider); final List> conversationMessages = >[]; for (final msg in messages) { // Skip only empty assistant message placeholders that are currently streaming // Include completed messages (both user and assistant) for conversation history if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { // Prepare cleaned text content (strip tool details etc.) final cleaned = ToolCallsParser.sanitizeForApi(msg.content); final List ids = msg.attachmentIds ?? const []; if (ids.isNotEmpty) { final messageMap = await _buildMessagePayloadWithAttachments( api: api, role: msg.role, cleanedText: cleaned, attachmentIds: ids, ); conversationMessages.add(messageMap); } else { // Regular text-only message conversationMessages.add({'role': msg.role, 'content': cleaned}); } } } final conversationSystemPrompt = activeConversation?.systemPrompt?.trim(); final effectiveSystemPrompt = (conversationSystemPrompt != null && conversationSystemPrompt.isNotEmpty) ? conversationSystemPrompt : userSystemPrompt; if (effectiveSystemPrompt != null && effectiveSystemPrompt.isNotEmpty) { final hasSystemMessage = conversationMessages.any( (m) => (m['role']?.toString().toLowerCase() ?? '') == 'system', ); if (!hasSystemMessage) { conversationMessages.insert(0, { 'role': 'system', 'content': effectiveSystemPrompt, }); } } // Check feature toggles for API (gated by server availability) final webSearchEnabled = ref.read(webSearchEnabledProvider) && ref.read(webSearchAvailableProvider); final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider); // Prepare tools list - pass tool IDs directly final List? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) ? toolIds : null; try { // Pre-seed assistant skeleton on server to ensure correct chain // Generate assistant message id now (must be consistent across client/server) final String assistantMessageId = const Uuid().v4(); // Add assistant placeholder locally before sending final assistantPlaceholder = ChatMessage( id: assistantMessageId, role: 'assistant', content: '', timestamp: DateTime.now(), model: selectedModel.id, isStreaming: true, ); ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder); // Sync conversation state to ensure WebUI can load conversation history try { final activeConvForSeed = ref.read(activeConversationProvider); if (activeConvForSeed != null) { final msgsForSeed = ref.read(chatMessagesProvider); await api.syncConversationMessages( activeConvForSeed.id, msgsForSeed, model: selectedModel.id, systemPrompt: effectiveSystemPrompt, ); } } catch (_) { // Non-critical - continue if sync fails } // Use the model's actual supported parameters if available final supportedParams = selectedModel.supportedParameters ?? [ 'max_tokens', 'tool_choice', 'tools', 'response_format', 'structured_outputs', ]; // Create comprehensive model item matching OpenWebUI format exactly final modelItem = { 'id': selectedModel.id, 'canonical_slug': selectedModel.id, 'hugging_face_id': '', 'name': selectedModel.name, 'created': 1754089419, // Use example timestamp for consistency 'description': selectedModel.description ?? 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrouter/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', 'context_length': 256000, 'architecture': { 'modality': 'text+image->text', 'input_modalities': ['image', 'text'], 'output_modalities': ['text'], 'tokenizer': 'Other', 'instruct_type': null, }, 'pricing': { 'prompt': '0', 'completion': '0', 'request': '0', 'image': '0', 'audio': '0', 'web_search': '0', 'internal_reasoning': '0', }, 'top_provider': { 'context_length': 256000, 'max_completion_tokens': 128000, 'is_moderated': false, }, 'per_request_limits': null, 'supported_parameters': supportedParams, 'connection_type': 'external', 'owned_by': 'openai', 'openai': { 'id': selectedModel.id, 'canonical_slug': selectedModel.id, 'hugging_face_id': '', 'name': selectedModel.name, 'created': 1754089419, 'description': selectedModel.description ?? 'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrout' 'er/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.', 'context_length': 256000, 'architecture': { 'modality': 'text+image->text', 'input_modalities': ['image', 'text'], 'output_modalities': ['text'], 'tokenizer': 'Other', 'instruct_type': null, }, 'pricing': { 'prompt': '0', 'completion': '0', 'request': '0', 'image': '0', 'audio': '0', 'web_search': '0', 'internal_reasoning': '0', }, 'top_provider': { 'context_length': 256000, 'max_completion_tokens': 128000, 'is_moderated': false, }, 'per_request_limits': null, 'supported_parameters': [ 'max_tokens', 'tool_choice', 'tools', 'response_format', 'structured_outputs', ], 'connection_type': 'external', }, 'urlIdx': 0, 'actions': [], 'filters': [], 'tags': [], }; // Stream response using server-push via Socket when available, otherwise fallback // Resolve Socket session for background tasks parity final socketService = ref.read(socketServiceProvider); final socketSessionId = socketService?.sessionId; final bool wantSessionBinding = (socketService?.isConnected == true) && (socketSessionId != null && socketSessionId.isNotEmpty); // Resolve tool servers from user settings (if any) List>? toolServers; final uiSettings = userSettingsData?['ui'] as Map?; final rawServers = uiSettings != null ? (uiSettings['toolServers'] as List?) : null; if (rawServers != null && rawServers.isNotEmpty) { try { toolServers = await _resolveToolServers(rawServers, api); } catch (_) {} } // Background tasks parity with Web client (safe defaults) // Enable title/tags generation on the very first user turn of a new chat. bool shouldGenerateTitle = false; try { final conv = ref.read(activeConversationProvider); // Use the outbound conversationMessages we just built (excludes streaming placeholders) final nonSystemCount = conversationMessages .where((m) => (m['role']?.toString() ?? '') != 'system') .length; shouldGenerateTitle = (conv == null) || ((conv.title == 'New Chat' || (conv.title.isEmpty)) && nonSystemCount == 1); } catch (_) {} // Match web client: request background follow-ups always; title/tags on first turn final bgTasks = { if (shouldGenerateTitle) 'title_generation': true, if (shouldGenerateTitle) 'tags_generation': true, 'follow_up_generation': true, if (webSearchEnabled) 'web_search': true, // enable bg web search if (imageGenerationEnabled) 'image_generation': true, // enable bg image flow }; // Determine if we need background task flow (tools/tool servers or web search) final bool isBackgroundToolsFlowPre = (toolIdsForApi != null && toolIdsForApi.isNotEmpty) || (toolServers != null && toolServers.isNotEmpty); final bool isBackgroundWebSearchPre = webSearchEnabled; final bool shouldBindSession = wantSessionBinding && (isBackgroundToolsFlowPre || isBackgroundWebSearchPre || imageGenerationEnabled || bgTasks.isNotEmpty); final response = await api.sendMessage( messages: conversationMessages, model: selectedModel.id, conversationId: activeConversation?.id, toolIds: toolIdsForApi, enableWebSearch: webSearchEnabled, // Enable image generation on the server when requested enableImageGeneration: imageGenerationEnabled, modelItem: modelItem, // Bind to Socket session whenever available so the server can push // streaming updates to this client (improves first-turn streaming). sessionIdOverride: shouldBindSession ? socketSessionId : null, socketSessionId: socketSessionId, toolServers: toolServers, backgroundTasks: bgTasks, responseMessageId: assistantMessageId, ); final stream = response.stream; final sessionId = response.sessionId; final effectiveSessionId = response.socketSessionId ?? socketSessionId ?? sessionId; // Use unified streaming helper for SSE/WebSocket handling final bool isBackgroundFlow = response.isBackgroundFlow; try { ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(( m, ) { final mergedMeta = { if (m.metadata != null) ...m.metadata!, 'backgroundFlow': isBackgroundFlow, if (isBackgroundWebSearchPre) 'webSearchFlow': true, if (imageGenerationEnabled) 'imageGenerationFlow': true, }; return m.copyWith(metadata: mergedMeta); }); } catch (_) {} final activeStream = attachUnifiedChunkedStreaming( stream: stream, webSearchEnabled: webSearchEnabled, assistantMessageId: assistantMessageId, modelId: selectedModel.id, modelItem: modelItem, sessionId: effectiveSessionId, activeConversationId: activeConversation?.id, api: api, socketService: socketService, appendToLastMessage: (c) => ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), replaceLastMessageContent: (c) => ref.read(chatMessagesProvider.notifier).replaceLastMessageContent(c), updateLastMessageWith: (updater) => ref .read(chatMessagesProvider.notifier) .updateLastMessageWithFunction(updater), appendStatusUpdate: (messageId, update) => ref .read(chatMessagesProvider.notifier) .appendStatusUpdate(messageId, update), setFollowUps: (messageId, followUps) => ref .read(chatMessagesProvider.notifier) .setFollowUps(messageId, followUps), upsertCodeExecution: (messageId, execution) => ref .read(chatMessagesProvider.notifier) .upsertCodeExecution(messageId, execution), appendSourceReference: (messageId, reference) => ref .read(chatMessagesProvider.notifier) .appendSourceReference(messageId, reference), updateMessageById: (messageId, updater) => ref .read(chatMessagesProvider.notifier) .updateMessageById(messageId, updater), onChatTitleUpdated: (newTitle) { final active = ref.read(activeConversationProvider); if (active != null) { ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); } ref.invalidate(conversationsProvider); }, onChatTagsUpdated: () { ref.invalidate(conversationsProvider); final active = ref.read(activeConversationProvider); final api = ref.read(apiServiceProvider); if (active != null && api != null) { Future.microtask(() async { try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); } catch (_) {} }); } }, finishStreaming: () => ref.read(chatMessagesProvider.notifier).finishStreaming(), getMessages: () => ref.read(chatMessagesProvider), ); ref.read(chatMessagesProvider.notifier) ..setMessageStream(activeStream.streamSubscription) ..setSocketSubscriptions( activeStream.socketSubscriptions, onDispose: activeStream.disposeWatchdog, ); return; } catch (e) { // Handle error - remove the assistant message placeholder ref.read(chatMessagesProvider.notifier).removeLastMessage(); // Add user-friendly error message instead of rethrowing if (e.toString().contains('400')) { final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '''⚠️ There was an issue with the message format. This might be because: • The image attachment couldn't be processed • The request format is incompatible with the selected model • The message contains unsupported content Please try sending the message again, or try without attachments.''', timestamp: DateTime.now(), isStreaming: false, ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else if (e.toString().contains('401') || e.toString().contains('403')) { // Authentication errors - clear auth state and redirect to login ref.invalidate(authStateManagerProvider); } else if (e.toString().contains('500')) { final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '⚠️ Unable to connect to the AI model. The server returned an error (500).\n\n' 'This is typically a server-side issue. Please try again or contact your administrator.', timestamp: DateTime.now(), isStreaming: false, ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else if (e.toString().contains('404')) { DebugLogger.log( 'Model or endpoint not found (404)', scope: 'chat/providers', ); final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '🤖 The selected AI model doesn\'t seem to be available.\n\n' 'Please try selecting a different model or check with your administrator.', timestamp: DateTime.now(), isStreaming: false, ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } else { // For other errors, provide a generic message and rethrow final errorMessage = ChatMessage( id: const Uuid().v4(), role: 'assistant', content: '❌ An unexpected error occurred while processing your request.\n\n' 'Please try again or check your connection.', timestamp: DateTime.now(), isStreaming: false, ); ref.read(chatMessagesProvider.notifier).addMessage(errorMessage); } } } // Save current conversation to OpenWebUI server // Removed server persistence; only local caching is used in mobile app. // Fallback: Save current conversation to local storage Future _saveConversationLocally(dynamic ref) async { try { final storage = ref.read(optimizedStorageServiceProvider); final messages = ref.read(chatMessagesProvider); final activeConversation = ref.read(activeConversationProvider); if (messages.isEmpty) return; // Create or update conversation locally final conversation = activeConversation ?? Conversation( id: const Uuid().v4(), title: _generateConversationTitle(messages), createdAt: DateTime.now(), updatedAt: DateTime.now(), messages: messages, ); final updatedConversation = conversation.copyWith( messages: messages, updatedAt: DateTime.now(), ); // 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, ); if (existingIndex >= 0) { conversations[existingIndex] = updatedConversation.toJson(); } else { conversations.add(updatedConversation.toJson()); } await storage.setString('conversations', jsonEncode(conversations)); ref.read(activeConversationProvider.notifier).set(updatedConversation); ref.invalidate(conversationsProvider); } catch (e) { // Handle local storage errors silently } } String _generateConversationTitle(List messages) { final firstUserMessage = messages.firstWhere( (msg) => msg.role == 'user', orElse: () => ChatMessage( id: '', role: 'user', content: 'New Chat', timestamp: DateTime.now(), ), ); // Use first 50 characters of the first user message as title final title = firstUserMessage.content.length > 50 ? '${firstUserMessage.content.substring(0, 50)}...' : firstUserMessage.content; return title.isEmpty ? 'New Chat' : title; } // Pin/Unpin conversation Future pinConversation( WidgetRef ref, String conversationId, bool pinned, ) async { try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service available'); await api.pinConversation(conversationId, pinned); // Refresh conversations list to reflect the change ref.invalidate(conversationsProvider); // Update active conversation if it's the one being pinned final activeConversation = ref.read(activeConversationProvider); if (activeConversation?.id == conversationId) { ref .read(activeConversationProvider.notifier) .set(activeConversation!.copyWith(pinned: pinned)); } } catch (e) { DebugLogger.log( 'Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e', scope: 'chat/providers', ); rethrow; } } // Archive/Unarchive conversation Future archiveConversation( WidgetRef ref, String conversationId, bool archived, ) async { final api = ref.read(apiServiceProvider); final activeConversation = ref.read(activeConversationProvider); // Update local state first if (activeConversation?.id == conversationId && archived) { ref.read(activeConversationProvider.notifier).clear(); ref.read(chatMessagesProvider.notifier).clearMessages(); } try { if (api == null) throw Exception('No API service available'); await api.archiveConversation(conversationId, archived); // Refresh conversations list to reflect the change ref.invalidate(conversationsProvider); } catch (e) { DebugLogger.log( 'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e', scope: 'chat/providers', ); // If server operation failed and we archived locally, restore the conversation if (activeConversation?.id == conversationId && archived) { ref.read(activeConversationProvider.notifier).set(activeConversation); // Messages will be restored through the listener } rethrow; } } // Share conversation Future shareConversation(WidgetRef ref, String conversationId) async { try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service available'); final shareId = await api.shareConversation(conversationId); // Refresh conversations list to reflect the change ref.invalidate(conversationsProvider); return shareId; } catch (e) { DebugLogger.log('Error sharing conversation: $e', scope: 'chat/providers'); rethrow; } } // Clone conversation Future cloneConversation(WidgetRef ref, String conversationId) async { try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service available'); final clonedConversation = await api.cloneConversation(conversationId); // Set the cloned conversation as active ref.read(activeConversationProvider.notifier).set(clonedConversation); // Load messages through the listener mechanism // The ChatMessagesNotifier will automatically load messages when activeConversation changes // Refresh conversations list to show the new conversation ref.invalidate(conversationsProvider); } catch (e) { DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers'); rethrow; } } // Regenerate last message final regenerateLastMessageProvider = Provider Function()>((ref) { return () async { final messages = ref.read(chatMessagesProvider); if (messages.length < 2) return; // 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]; break; } } if (lastUserMessage == null) return; // Remove last assistant message ref.read(chatMessagesProvider.notifier).removeLastMessage(); // If previous assistant was image-only or had images, regenerate images instead of text if (lastAssistantHadImages) { final prev = ref.read(imageGenerationEnabledProvider); try { // Force image generation enabled during regeneration ref.read(imageGenerationEnabledProvider.notifier).set(true); await regenerateMessage( ref, lastUserMessage.content, lastUserMessage.attachmentIds, ); } finally { // restore previous state ref.read(imageGenerationEnabledProvider.notifier).set(prev); } return; } // Text regeneration without duplicating user message await regenerateMessage( ref, lastUserMessage.content, lastUserMessage.attachmentIds, ); }; }); // Stop generation provider final stopGenerationProvider = Provider((ref) { return () { try { final messages = ref.read(chatMessagesProvider); if (messages.isNotEmpty && messages.last.role == 'assistant' && messages.last.isStreaming) { final lastId = messages.last.id; // Cancel the network stream (SSE) if active final api = ref.read(apiServiceProvider); api?.cancelStreamingMessage(lastId); // Cancel local stream subscription to stop propagating further chunks ref.read(chatMessagesProvider.notifier).cancelActiveMessageStream(); } } catch (_) {} // Best-effort: stop any background tasks associated with this chat (parity with web) try { final api = ref.read(apiServiceProvider); final activeConv = ref.read(activeConversationProvider); if (api != null && activeConv != null) { unawaited(() async { try { final ids = await api.getTaskIdsByChat(activeConv.id); for (final t in ids) { try { await api.stopTask(t); } catch (_) {} } } catch (_) {} }()); // Also cancel local queue tasks for this conversation try { // Fire-and-forget local queue cancellation // ignore: unawaited_futures ref .read(taskQueueProvider.notifier) .cancelByConversation(activeConv.id); } catch (_) {} } } catch (_) {} // Ensure UI transitions out of streaming state ref.read(chatMessagesProvider.notifier).finishStreaming(); }; }); // ========== Shared Streaming Utilities ========== // ========== Tool Servers (OpenAPI) Helpers ========== Future>> _resolveToolServers( List rawServers, dynamic api, ) async { final List> resolved = []; for (final s in rawServers) { try { if (s is! Map) continue; final cfg = s['config']; if (cfg is Map && cfg['enable'] != true) continue; final url = (s['url'] ?? '').toString(); final path = (s['path'] ?? '').toString(); if (url.isEmpty || path.isEmpty) continue; final fullUrl = path.contains('://') ? path : '$url${path.startsWith('/') ? '' : '/'}$path'; // Fetch OpenAPI spec (supports YAML/JSON) Map? openapi; try { final resp = await api.dio.get(fullUrl); final ct = resp.headers.map['content-type']?.join(',') ?? ''; if (fullUrl.toLowerCase().endsWith('.yaml') || fullUrl.toLowerCase().endsWith('.yml') || ct.contains('yaml')) { final doc = yaml.loadYaml(resp.data); openapi = json.decode(json.encode(doc)) as Map; } else { final data = resp.data; if (data is Map) { openapi = data; } else if (data is String) { openapi = json.decode(data) as Map; } } } catch (_) { continue; } if (openapi == null) continue; // Convert OpenAPI to tool specs final specs = _convertOpenApiToToolPayload(openapi); resolved.add({ 'url': url, 'openapi': openapi, 'info': openapi['info'], 'specs': specs, }); } catch (_) { continue; } } return resolved; } Map? _resolveRef( String ref, Map? components, ) { // e.g., #/components/schemas/MySchema if (!ref.startsWith('#/')) return null; final parts = ref.split('/'); if (parts.length < 4) return null; final type = parts[2]; // schemas final name = parts[3]; final section = components?[type]; if (section is Map) { final schema = section[name]; if (schema is Map) { return Map.from(schema); } } return null; } Map _resolveSchemaSimple( dynamic schema, Map? components, ) { if (schema is Map) { if (schema.containsKey(r'$ref')) { final ref = schema[r'$ref'] as String; final resolved = _resolveRef(ref, components); if (resolved != null) return _resolveSchemaSimple(resolved, components); } final type = schema['type']; final out = {}; if (type is String) { out['type'] = type; if (schema['description'] != null) { out['description'] = schema['description']; } if (type == 'object') { out['properties'] = {}; if (schema['required'] is List) { out['required'] = List.from(schema['required']); } final props = schema['properties']; if (props is Map) { props.forEach((k, v) { out['properties'][k] = _resolveSchemaSimple(v, components); }); } } else if (type == 'array') { out['items'] = _resolveSchemaSimple(schema['items'], components); } } return out; } return {}; } List> _convertOpenApiToToolPayload( Map openApi, ) { final tools = >[]; final paths = openApi['paths']; if (paths is! Map) return tools; paths.forEach((path, methods) { if (methods is! Map) return; methods.forEach((method, operation) { if (operation is Map && operation['operationId'] != null) { final tool = { 'name': operation['operationId'], 'description': operation['description'] ?? operation['summary'] ?? 'No description available.', 'parameters': { 'type': 'object', 'properties': {}, 'required': [], }, }; // Parameters final params = operation['parameters']; if (params is List) { for (final p in params) { if (p is Map) { final name = p['name']; final schema = p['schema'] as Map?; if (name != null && schema != null) { String desc = (schema['description'] ?? p['description'] ?? '') .toString(); if (schema['enum'] is List) { desc = '$desc. Possible values: ${(schema['enum'] as List).join(', ')}'; } tool['parameters']['properties'][name] = { 'type': schema['type'], 'description': desc, }; if (p['required'] == true) { (tool['parameters']['required'] as List).add(name); } } } } } // requestBody final reqBody = operation['requestBody']; if (reqBody is Map) { final content = reqBody['content']; if (content is Map && content['application/json'] is Map) { final schema = content['application/json']['schema']; final resolved = _resolveSchemaSimple( schema, openApi['components'] as Map?, ); if (resolved['properties'] is Map) { tool['parameters']['properties'] = { ...tool['parameters']['properties'], ...resolved['properties'] as Map, }; if (resolved['required'] is List) { final req = Set.from(tool['parameters']['required'] as List) ..addAll(resolved['required'] as List); tool['parameters']['required'] = req.toList(); } } else if (resolved['type'] == 'array') { tool['parameters'] = resolved; } } } tools.add(tool); } }); }); return tools; }