Files
iiEsaywebUIapp/lib/features/chat/providers/chat_providers.dart

1901 lines
68 KiB
Dart
Raw Normal View History

2025-08-10 01:20:45 +05:30
import 'dart:async';
2025-08-12 13:07:10 +05:30
import 'dart:convert';
2025-08-10 01:20:45 +05:30
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.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/stream_chunker.dart';
import '../../../core/services/persistent_streaming_service.dart';
2025-08-17 16:11:19 +05:30
import '../services/reviewer_mode_service.dart';
2025-08-10 01:20:45 +05:30
// Chat messages for current conversation
final chatMessagesProvider =
StateNotifierProvider<ChatMessagesNotifier, List<ChatMessage>>((ref) {
return ChatMessagesNotifier(ref);
});
// Loading state for conversation (used to show chat skeletons during fetch)
final isLoadingConversationProvider = StateProvider<bool>((ref) => false);
class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
final Ref _ref;
StreamSubscription? _messageStream;
ProviderSubscription? _conversationListener;
final List<StreamSubscription> _subscriptions = [];
ChatMessagesNotifier(this._ref) : super([]) {
// Load messages when conversation changes with proper cleanup
_conversationListener = _ref.listen(activeConversationProvider, (
previous,
next,
) {
debugPrint(
'DEBUG: Active conversation changed - Previous: ${previous?.id}, Next: ${next?.id}',
);
// Only react when the conversation actually changes
if (previous?.id == next?.id) {
// If same conversation but server updated it (e.g., title/content), sync messages without flicker
if (previous?.updatedAt != next?.updatedAt) {
state = next?.messages ?? state;
}
return;
}
// Cancel any existing message stream when switching conversations
_cancelMessageStream();
if (next != null) {
debugPrint(
'DEBUG: Loading ${next.messages.length} messages for conversation ${next.id}',
);
state = next.messages;
2025-08-17 00:26:12 +05:30
// Update selected model if conversation has a different model
_updateModelForConversation(next);
2025-08-10 01:20:45 +05:30
} else {
debugPrint('DEBUG: Clearing messages - no active conversation');
state = [];
}
});
// ProviderSubscription will be cleaned up in dispose method
}
void _addSubscription(StreamSubscription subscription) {
_subscriptions.add(subscription);
}
void _cancelMessageStream() {
_messageStream?.cancel();
_messageStream = null;
}
2025-08-17 00:26:12 +05:30
Future<void> _updateModelForConversation(Conversation conversation) async {
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');
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).state = conversationModel;
debugPrint(
'DEBUG: Updated selected model to ${conversationModel.name} (${conversationModel.id}) for conversation ${conversation.id}',
);
} else {
debugPrint(
'DEBUG: Conversation model ${conversation.model} not found in available models: ${models.map((m) => m.id).join(', ')}',
);
}
} catch (e) {
debugPrint('DEBUG: Failed to update model for conversation: $e');
}
}
}
2025-08-10 01:20:45 +05:30
void setMessageStream(StreamSubscription stream) {
_cancelMessageStream();
_messageStream = stream;
// Add to tracked subscriptions for comprehensive cleanup
_addSubscription(stream);
}
void addMessage(ChatMessage message) {
state = [...state, message];
}
void removeLastMessage() {
if (state.isNotEmpty) {
state = state.sublist(0, state.length - 1);
}
}
void clearMessages() {
state = [];
}
void setMessages(List<ChatMessage> messages) {
state = messages;
}
void updateLastMessage(String content) {
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant') return;
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: content),
];
}
2025-08-19 13:09:40 +05:30
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),
];
}
2025-08-10 01:20:45 +05:30
void appendToLastMessage(String content) {
debugPrint('DEBUG: appendToLastMessage called with: "$content"');
if (state.isEmpty) {
debugPrint('DEBUG: No messages to append to');
return;
}
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
debugPrint(
'DEBUG: Last message is not assistant, role: ${lastMessage.role}',
);
return;
}
debugPrint(
'DEBUG: Appending to message ${lastMessage.id}, current length: ${lastMessage.content.length}',
);
// If the current content is just the typing indicator, replace it instead of appending
final newContent = lastMessage.content == '[TYPING_INDICATOR]'
? content
: lastMessage.content + content;
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: newContent),
];
debugPrint('DEBUG: New content length: ${state.last.content.length}');
}
void replaceLastMessageContent(String content) {
debugPrint('DEBUG: replaceLastMessageContent called with: "$content"');
if (state.isEmpty) {
debugPrint('DEBUG: No messages to replace content for');
return;
}
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
debugPrint(
'DEBUG: Last message is not assistant, role: ${lastMessage.role}',
);
return;
}
debugPrint('DEBUG: Replacing content for message ${lastMessage.id}');
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: content),
];
debugPrint('DEBUG: Replaced content length: ${state.last.content.length}');
}
void finishStreaming() {
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return;
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(isStreaming: false),
];
}
@override
void dispose() {
debugPrint(
'DEBUG: ChatMessagesNotifier disposing - cancelling ${_subscriptions.length} subscriptions',
);
// Cancel all tracked subscriptions
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
// Cancel message stream specifically
_cancelMessageStream();
// Cancel conversation listener specifically
_conversationListener?.close();
_conversationListener = null;
debugPrint('DEBUG: ChatMessagesNotifier disposed successfully');
super.dispose();
}
}
// Start a new chat (unified function for both "New Chat" button and home screen)
void startNewChat(dynamic ref) {
debugPrint('DEBUG: Starting new chat - clearing all state');
// Clear active conversation
ref.read(activeConversationProvider.notifier).state = null;
// Clear messages
ref.read(chatMessagesProvider.notifier).clearMessages();
debugPrint('DEBUG: New chat state cleared');
}
// Available tools provider
final availableToolsProvider = StateProvider<List<String>>((ref) => []);
// Web search enabled state for API-based web search
final webSearchEnabledProvider = StateProvider<bool>((ref) => false);
// Vision capable models provider
final visionCapableModelsProvider = StateProvider<List<String>>((ref) {
final selectedModel = ref.watch(selectedModelProvider);
if (selectedModel == null) return [];
// Check if the model supports vision (multimodal)
if (selectedModel.isMultimodal == true) {
return [selectedModel.id];
}
// For now, assume all models support vision unless explicitly marked
// This can be enhanced with proper model capability detection
return [selectedModel.id];
});
// File upload capable models provider
final fileUploadCapableModelsProvider = StateProvider<List<String>>((ref) {
final selectedModel = ref.watch(selectedModelProvider);
if (selectedModel == null) return [];
// For now, assume all models support file upload
// This can be enhanced with proper model capability detection
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 get file content as base64
Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
debugPrint('DEBUG: _getFileAsBase64 called for fileId: $fileId');
// Check if this is already a data URL (for images)
if (fileId.startsWith('data:')) {
debugPrint('DEBUG: FileId is already a data URL, returning as-is');
return fileId;
}
try {
// First, get file info to determine if it's an image
debugPrint('DEBUG: Getting file info for $fileId');
final fileInfo = await api.getFileInfo(fileId);
debugPrint('DEBUG: File info received: $fileInfo');
// 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'] ??
'';
debugPrint('DEBUG: Processing file: $fileName (fileId: $fileId)');
final ext = fileName.toLowerCase().split('.').last;
debugPrint('DEBUG: File extension: $ext');
// Only process image files
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
debugPrint('DEBUG: Skipping non-image file: $fileName (extension: $ext)');
return null;
}
debugPrint('DEBUG: Getting base64 content for image: $fileName');
// Get file content as base64 string
final fileContent = await api.getFileContent(fileId);
debugPrint(
'DEBUG: Got file content for $fileName, type: ${fileContent.runtimeType}, length: ${fileContent.length}',
);
// The API service returns base64 string directly
return fileContent;
} catch (e) {
debugPrint('DEBUG: Error getting file content for $fileId: $e');
return null;
}
}
// Regenerate message function that doesn't duplicate user message
Future<void> regenerateMessage(
WidgetRef ref,
String userMessageContent,
List<String>? attachments,
) async {
debugPrint('DEBUG: regenerateMessage called with content: $userMessageContent');
final reviewerMode = ref.read(reviewerModeProvider);
final api = ref.read(apiServiceProvider);
final selectedModel = ref.read(selectedModelProvider);
if ((!reviewerMode && api == null) || selectedModel == null) {
debugPrint('DEBUG: Missing API service or model for regeneration');
throw Exception('No API service or model selected');
}
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation == null) {
debugPrint('DEBUG: No active conversation for regeneration');
throw Exception('No active conversation');
}
// In reviewer mode, simulate response
if (reviewerMode) {
final assistantMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content: '[TYPING_INDICATOR]',
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
2025-08-17 16:11:19 +05:30
// Use canned response for regeneration
final responseText = ReviewerModeService.generateResponse(
userMessage: userMessageContent,
);
// Simulate streaming response
2025-08-17 16:11:19 +05:30
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 {
// Get conversation history for context (excluding the removed assistant message)
final List<ChatMessage> messages = ref.read(chatMessagesProvider);
final List<Map<String, dynamic>> conversationMessages = <Map<String, dynamic>>[];
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<Map<String, dynamic>> contentArray = [];
// Add text content first
if (msg.content.isNotEmpty) {
contentArray.add({'type': 'text', 'text': msg.content});
}
conversationMessages.add({
'role': msg.role,
'content': contentArray.isNotEmpty ? contentArray : msg.content,
});
} else {
// Regular text message
conversationMessages.add({
'role': msg.role,
'content': msg.content,
});
}
}
}
// Stream response using SSE
2025-08-17 16:11:19 +05:30
final response = api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
);
final stream = response.stream;
final assistantMessageId = response.messageId;
// Add assistant message placeholder
final assistantMessage = ChatMessage(
id: assistantMessageId,
role: 'assistant',
content: '[TYPING_INDICATOR]',
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
// Handle streaming response
final chunkedStream = StreamChunker.chunkStream(
stream,
enableChunking: true,
minChunkSize: 5,
maxChunkLength: 3,
delayBetweenChunks: const Duration(milliseconds: 15),
);
await for (final chunk in chunkedStream) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk);
}
ref.read(chatMessagesProvider.notifier).finishStreaming();
await _saveConversationLocally(ref);
} catch (e) {
debugPrint('DEBUG: Error during message regeneration: $e');
rethrow;
}
}
2025-08-10 01:20:45 +05:30
// Send message function for widgets
Future<void> sendMessage(
WidgetRef ref,
String message,
2025-08-19 20:26:19 +05:30
List<String>? attachments, [
List<String>? toolIds,
]) async {
2025-08-10 01:20:45 +05:30
debugPrint(
2025-08-19 20:26:19 +05:30
'DEBUG: sendMessage called with message: $message, attachments: $attachments, tools: $toolIds',
2025-08-10 01:20:45 +05:30
);
2025-08-19 20:26:19 +05:30
await _sendMessageInternal(ref, message, attachments, toolIds);
2025-08-10 01:20:45 +05:30
}
// Internal send message implementation
Future<void> _sendMessageInternal(
dynamic ref,
String message,
2025-08-19 20:26:19 +05:30
List<String>? attachments, [
List<String>? toolIds,
]) async {
2025-08-10 01:20:45 +05:30
debugPrint('DEBUG: _sendMessageInternal called');
debugPrint('DEBUG: Message: $message');
debugPrint('DEBUG: Attachments: $attachments');
final reviewerMode = ref.read(reviewerModeProvider);
final api = ref.read(apiServiceProvider);
final selectedModel = ref.read(selectedModelProvider);
debugPrint('DEBUG: API service: ${api != null ? 'available' : 'null'}');
debugPrint('DEBUG: Selected model: ${selectedModel?.name ?? 'null'}');
if ((!reviewerMode && api == null) || selectedModel == null) {
debugPrint('DEBUG: Missing API service or model');
throw Exception('No API service or model selected');
}
// Check if we need to create a new conversation first
var activeConversation = ref.read(activeConversationProvider);
2025-08-12 13:07:10 +05:30
debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}');
// Create user message first
2025-08-19 20:26:19 +05:30
debugPrint('DEBUG: Creating user message with attachments: $attachments, tools: $toolIds');
2025-08-12 13:07:10 +05:30
final userMessage = ChatMessage(
id: const Uuid().v4(),
role: 'user',
content: message,
timestamp: DateTime.now(),
attachmentIds: attachments,
);
2025-08-10 01:20:45 +05:30
if (activeConversation == null) {
2025-08-12 13:07:10 +05:30
// Create new conversation with the first message included
debugPrint('DEBUG: Creating new conversation with first message');
2025-08-10 01:20:45 +05:30
// Create local conversation first
final localConversation = Conversation(
id: const Uuid().v4(),
2025-08-12 13:07:10 +05:30
title: 'New Chat',
2025-08-10 01:20:45 +05:30
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
2025-08-12 13:07:10 +05:30
messages: [userMessage], // Include the user message
2025-08-10 01:20:45 +05:30
);
// Set as active conversation locally
ref.read(activeConversationProvider.notifier).state = localConversation;
activeConversation = localConversation;
if (!reviewerMode) {
2025-08-12 13:07:10 +05:30
// Try to create on server with the first message included
2025-08-10 01:20:45 +05:30
try {
final serverConversation = await api.createConversation(
2025-08-12 13:07:10 +05:30
title: 'New Chat',
messages: [userMessage], // Include the first message in creation
2025-08-10 01:20:45 +05:30
model: selectedModel.id,
);
final updatedConversation = localConversation.copyWith(
id: serverConversation.id,
2025-08-12 13:07:10 +05:30
messages: serverConversation.messages.isNotEmpty
? serverConversation.messages
: [userMessage],
2025-08-10 01:20:45 +05:30
);
ref.read(activeConversationProvider.notifier).state =
updatedConversation;
activeConversation = updatedConversation;
2025-08-12 13:07:10 +05:30
// 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',
);
2025-08-10 01:20:45 +05:30
debugPrint(
2025-08-12 13:07:10 +05:30
'DEBUG: Server conversation ID: ${serverConversation.id}, Title: ${serverConversation.title}',
2025-08-10 01:20:45 +05:30
);
2025-08-17 00:26:12 +05:30
// Invalidate conversations provider to refresh the list
2025-08-17 16:11:19 +05:30
// Adding a small delay to prevent rapid invalidations that could cause duplicates
Future.delayed(const Duration(milliseconds: 100), () {
ref.invalidate(conversationsProvider);
});
2025-08-10 01:20:45 +05:30
} catch (e) {
debugPrint(
'DEBUG: Failed to create conversation on server, using local: $e',
);
2025-08-12 13:07:10 +05:30
// Still add the message locally
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
} else {
// Add message for reviewer mode
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
} else {
// Add user message to existing conversation
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
debugPrint('DEBUG: User message added with ID: ${userMessage.id}');
2025-08-10 01:20:45 +05:30
}
// 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: '[TYPING_INDICATOR]',
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
2025-08-17 16:11:19 +05:30
// 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,
);
2025-08-10 01:20:45 +05:30
// Simulate token-by-token streaming
2025-08-17 16:11:19 +05:30
final words = responseText.split(' ');
2025-08-10 01:20:45 +05:30
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<ChatMessage> messages = ref.read(chatMessagesProvider);
final List<Map<String, dynamic>> conversationMessages =
<Map<String, dynamic>>[];
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) {
debugPrint(
'DEBUG: Processing message: role=${msg.role}, content=${msg.content.substring(0, msg.content.length > 50 ? 50 : msg.content.length)}..., attachments=${msg.attachmentIds}',
);
// Check if message has attachments (images and non-images)
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
debugPrint(
'DEBUG: Message has ${msg.attachmentIds!.length} attachments',
);
// Check if this is a Gemini model that requires special handling
final isGeminiModel = selectedModel.id.toLowerCase().contains('gemini');
debugPrint('DEBUG: Is Gemini model: $isGeminiModel');
debugPrint('DEBUG: Model ID: ${selectedModel.id}');
debugPrint('DEBUG: Model name: ${selectedModel.name}');
debugPrint(
'DEBUG: Model ID lowercase: ${selectedModel.id.toLowerCase()}',
);
debugPrint(
'DEBUG: Contains gemini: ${selectedModel.id.toLowerCase().contains('gemini')}',
);
// Use the same content array format for all models (OpenWebUI standard)
final List<Map<String, dynamic>> contentArray = [];
// Collect non-image files to include in the message map so API can forward top-level 'files'
final List<Map<String, dynamic>> nonImageFiles = [];
// Add text content first
if (msg.content.isNotEmpty) {
contentArray.add({'type': 'text', 'text': msg.content});
debugPrint('DEBUG: Added text content to array');
}
// Add image attachments with proper MIME type handling; collect non-image attachments
for (final attachmentId in msg.attachmentIds!) {
debugPrint('DEBUG: Processing attachment: $attachmentId');
try {
final base64Data = await _getFileAsBase64(api, attachmentId);
if (base64Data != null) {
debugPrint(
'DEBUG: Got base64 data for attachment $attachmentId, length: ${base64Data.length}',
);
// Check if this is already a data URL
if (base64Data.startsWith('data:')) {
contentArray.add({
'type': 'image_url',
'image_url': {'url': base64Data},
});
debugPrint('DEBUG: Added image with data URL');
} else {
// For server files, determine MIME type from file extension
// Only call getFileInfo if attachmentId is not a data URL
if (!attachmentId.startsWith('data:')) {
final fileInfo = await api.getFileInfo(attachmentId);
final fileName = fileInfo['filename'] ?? '';
final ext = fileName.toLowerCase().split('.').last;
String mimeType = 'image/png'; // default
if (ext == 'jpg' || ext == 'jpeg') {
mimeType = 'image/jpeg';
} else if (ext == 'gif') {
mimeType = 'image/gif';
} else if (ext == 'webp') {
mimeType = 'image/webp';
}
debugPrint(
'DEBUG: Using MIME type: $mimeType for file: $fileName',
);
contentArray.add({
'type': 'image_url',
'image_url': {'url': 'data:$mimeType;base64,$base64Data'},
});
debugPrint('DEBUG: Added image with MIME type: $mimeType');
} else {
debugPrint('DEBUG: Skipping getFileInfo for data URL');
}
}
} else {
debugPrint(
'DEBUG: No base64 data returned for attachment $attachmentId',
);
// Treat as non-image file; include minimal descriptor so server can resolve by id
nonImageFiles.add({'id': attachmentId, 'type': 'file'});
}
} catch (e) {
debugPrint('DEBUG: Failed to load attachment $attachmentId: $e');
}
}
debugPrint('DEBUG: Final content array length: ${contentArray.length}');
final messageMap = <String, dynamic>{
'role': msg.role,
'content': contentArray,
};
if (nonImageFiles.isNotEmpty) {
debugPrint(
'DEBUG: Adding ${nonImageFiles.length} non-image file(s) to message map',
);
messageMap['files'] = nonImageFiles;
}
conversationMessages.add(messageMap);
} else {
// Regular text-only message
debugPrint('DEBUG: Regular text-only message');
conversationMessages.add({'role': msg.role, 'content': msg.content});
}
}
}
// Check if web search is enabled for API
final webSearchEnabled = ref.read(webSearchEnabledProvider);
2025-08-19 13:09:40 +05:30
// Debug log to track web search state
debugPrint('DEBUG: Web search toggle state: $webSearchEnabled');
2025-08-10 01:20:45 +05:30
2025-08-19 20:26:19 +05:30
// Prepare tools list - pass tool IDs directly
final List<String>? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) ? toolIds : null;
if (toolIdsForApi != null) {
debugPrint('DEBUG: Including tool IDs: $toolIdsForApi');
}
2025-08-10 01:20:45 +05:30
try {
// Use the model's actual supported parameters if available
final supportedParams =
selectedModel.supportedParameters ??
[
'max_tokens',
'tool_choice',
'tools',
'response_format',
'structured_outputs',
];
debugPrint(
'DEBUG: Model ${selectedModel.name} supported parameters: ${selectedModel.supportedParameters}',
);
debugPrint('DEBUG: Model ID: ${selectedModel.id}');
debugPrint('DEBUG: Is multimodal: ${selectedModel.isMultimodal}');
// 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': <dynamic>[],
'filters': <dynamic>[],
'tags': <dynamic>[],
};
debugPrint('DEBUG: Using basic model item for ${selectedModel.name}');
debugPrint('DEBUG: Final conversationMessages being sent to API:');
debugPrint('DEBUG: Messages count: ${conversationMessages.length}');
for (int i = 0; i < conversationMessages.length; i++) {
final msg = conversationMessages[i];
debugPrint(
'DEBUG: Message $i: role=${msg['role']}, content type=${msg['content'].runtimeType}',
);
if (msg['content'] is List) {
final contentArray = msg['content'] as List;
debugPrint(
'DEBUG: Message $i content array length: ${contentArray.length}',
);
for (int j = 0; j < contentArray.length; j++) {
final item = contentArray[j];
debugPrint(
'DEBUG: Content item $j: type=${item['type']}, has_image_url=${item.containsKey('image_url')}',
);
}
}
}
2025-08-16 17:36:02 +05:30
// Stream response using SSE
final response = await api.sendMessage(
2025-08-10 01:20:45 +05:30
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation?.id,
2025-08-19 20:26:19 +05:30
toolIds: toolIdsForApi,
2025-08-10 01:20:45 +05:30
enableWebSearch: webSearchEnabled,
modelItem: modelItem,
);
final stream = response.stream;
final assistantMessageId = response.messageId;
final sessionId = response.sessionId;
debugPrint(
'DEBUG: Response IDs - Message: $assistantMessageId, Session: $sessionId',
);
// Add assistant message placeholder with the generated ID and immediate typing indicator
final assistantMessage = ChatMessage(
id: assistantMessageId,
role: 'assistant',
content: '[TYPING_INDICATOR]', // Show typing indicator immediately
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
// For built-in web search, the status will be updated when function calls are detected
// in the streaming response. Manual status update is not needed here.
// Set up stream subscription with proper management
// Apply chunking for smoother word-by-word streaming
final chunkedStream = StreamChunker.chunkStream(
stream,
enableChunking: true,
minChunkSize: 5,
maxChunkLength: 3,
delayBetweenChunks: const Duration(milliseconds: 15),
);
// Create a stream controller for persistent handling
final persistentController = StreamController<String>.broadcast();
// Register stream with persistent service for app lifecycle handling
final persistentService = PersistentStreamingService();
final streamId = persistentService.registerStream(
subscription: chunkedStream.listen(
(chunk) {
persistentController.add(chunk);
},
onDone: () {
persistentController.close();
},
onError: (error) {
persistentController.addError(error);
},
),
controller: persistentController,
recoveryCallback: () async {
// Recovery callback to restart streaming if interrupted
debugPrint('DEBUG: Attempting to recover interrupted stream');
// TODO: Implement stream recovery logic
},
metadata: {
'conversationId': activeConversation?.id,
'messageId': assistantMessageId,
'modelId': selectedModel.id,
},
);
2025-08-19 13:09:40 +05:30
// Track web search status
bool isSearching = false;
final streamSubscription = persistentController.stream.listen(
2025-08-10 01:20:45 +05:30
(chunk) {
debugPrint('DEBUG: Received stream chunk: "$chunk"');
2025-08-19 13:09:40 +05:30
// 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') ||
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},
),
);
return; // Don't append this chunk
}
}
// Check if web search is 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},
),
);
return; // Don't append this chunk
}
// Regular content - append to message
if (!chunk.contains('[SEARCHING]') && !chunk.contains('[/SEARCHING]')) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk);
}
2025-08-10 01:20:45 +05:30
},
onDone: () async {
// Unregister from persistent service
persistentService.unregisterStream(streamId);
2025-08-10 01:20:45 +05:30
debugPrint('DEBUG: Stream completed in chat provider');
2025-08-12 13:07:10 +05:30
// Mark streaming as complete immediately for better UX
ref.read(chatMessagesProvider.notifier).finishStreaming();
2025-08-10 01:20:45 +05:30
// Send chat completed notification to OpenWebUI
final messages = ref.read(chatMessagesProvider);
if (messages.isNotEmpty && activeConversation != null) {
final lastMessage = messages.last;
if (lastMessage.role == 'assistant') {
try {
// Convert messages to the format expected by /api/chat/completed
final List<Map<String, dynamic>> formattedMessages = [];
for (final msg in messages) {
final messageMap = <String, dynamic>{
'id': msg.id,
'role': msg.role,
'content': msg.content,
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
};
2025-08-12 13:07:10 +05:30
// 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;
} else if (msg == messages.last) {
// Add basic usage for the last assistant message
messageMap['usage'] = {
'prompt_tokens': 10,
'completion_tokens': msg.content.split(' ').length,
'total_tokens': 10 + msg.content.split(' ').length,
};
}
2025-08-10 01:20:45 +05:30
}
formattedMessages.add(messageMap);
}
// Send chat completed notification to OpenWebUI first
try {
debugPrint(
'DEBUG: Sending chat completed notification to OpenWebUI',
);
2025-08-12 13:07:10 +05:30
debugPrint(
'DEBUG: Active conversation ID: ${activeConversation.id}',
);
2025-08-10 01:20:45 +05:30
debugPrint(
'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}',
);
await api.sendChatCompleted(
chatId: activeConversation.id,
messageId: assistantMessageId, // Use message ID from response
messages: formattedMessages,
model: selectedModel.id,
modelItem: modelItem, // Include model metadata
sessionId: sessionId, // Include session ID
);
debugPrint(
2025-08-12 13:07:10 +05:30
'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}',
2025-08-10 01:20:45 +05:30
);
} catch (e) {
debugPrint('DEBUG: Chat completed notification failed: $e');
2025-08-12 13:07:10 +05:30
debugPrint('DEBUG: Error details: $e');
// Continue even if this fails - it's non-critical
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
// Fetch the latest conversation state without waiting for title generation
debugPrint('DEBUG: Fetching latest conversation state...');
debugPrint('DEBUG: Current message count: ${messages.length}');
2025-08-10 01:20:45 +05:30
try {
2025-08-12 13:07:10 +05:30
// Quick fetch to get the current state - no waiting for title generation
2025-08-10 01:20:45 +05:30
final updatedConv = await api.getConversation(
activeConversation.id,
);
2025-08-12 13:07:10 +05:30
debugPrint('DEBUG: Current title: ${updatedConv.title}');
2025-08-10 01:20:45 +05:30
2025-08-12 13:07:10 +05:30
// Check if we should update the title (only on first response and if server has one)
2025-08-10 01:20:45 +05:30
final shouldUpdateTitle =
messages.length <= 2 &&
updatedConv.title != 'New Chat' &&
updatedConv.title.isNotEmpty;
2025-08-16 17:36:02 +05:30
// 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);
}
2025-08-10 01:20:45 +05:30
// Always combine current local messages with updated server content
final currentMessages = ref.read(chatMessagesProvider);
final serverMessages = updatedConv.messages;
// Create a map of server messages by ID for quick lookup
final serverMessageMap = <String, ChatMessage>{};
for (final serverMsg in serverMessages) {
serverMessageMap[serverMsg.id] = serverMsg;
}
// Update local messages with server content while preserving all messages
final updatedMessages = <ChatMessage>[];
for (final localMsg in currentMessages) {
final serverMsg = serverMessageMap[localMsg.id];
if (serverMsg != null && serverMsg.content.isNotEmpty) {
// Use server content if available and non-empty
// This replaces any temporary progress indicators with real content
debugPrint(
'DEBUG: Replacing local content with server content for message ${localMsg.id}',
);
debugPrint(
'DEBUG: Local content: "${localMsg.content.substring(0, math.min(100, localMsg.content.length))}..."',
);
debugPrint(
'DEBUG: Server content: "${serverMsg.content.substring(0, math.min(100, serverMsg.content.length))}..."',
);
// Stream the server content through StreamChunker for word-by-word effect
debugPrint(
'DEBUG: Streaming server content through chunker for word-by-word display',
);
// Clear only the last message content in-place to avoid list reset flicker
final currentList = [...currentMessages];
final lastIndex = currentList.lastIndexWhere(
(m) => m.id == localMsg.id,
);
if (lastIndex != -1) {
currentList[lastIndex] = currentList[lastIndex].copyWith(
content: '',
isStreaming: true,
);
ref
.read(chatMessagesProvider.notifier)
.setMessages(currentList);
}
// Create a stream from the server content and chunk it
final serverContentStream = Stream.fromIterable([
serverMsg.content,
]);
final chunkedStream = StreamChunker.chunkStream(
serverContentStream,
enableChunking: true,
minChunkSize: 5,
maxChunkLength: 3,
delayBetweenChunks: const Duration(milliseconds: 25),
);
// Process chunks
chunkedStream.listen(
(chunk) {
debugPrint('DEBUG: Server content chunk: "$chunk"');
ref
.read(chatMessagesProvider.notifier)
.appendToLastMessage(chunk);
},
onDone: () {
debugPrint('DEBUG: Server content streaming completed');
// Mark streaming as complete
ref
.read(chatMessagesProvider.notifier)
.finishStreaming();
},
onError: (error) {
debugPrint(
'DEBUG: Server content streaming error: $error',
);
// Fall back to direct replacement
final currentMessages = ref.read(chatMessagesProvider);
if (currentMessages.isNotEmpty) {
final fallbackMessages = [...currentMessages];
final lastIndex = fallbackMessages.length - 1;
fallbackMessages[lastIndex] =
fallbackMessages[lastIndex].copyWith(
content: serverMsg.content,
isStreaming: false,
);
ref
.read(chatMessagesProvider.notifier)
.setMessages(fallbackMessages);
}
},
);
// Don't add to updatedMessages here since we're streaming
continue;
} else {
// Handle case where streaming failed and we still have typing indicator
if (localMsg.content == '[TYPING_INDICATOR]') {
debugPrint(
'DEBUG: Found orphaned typing indicator for message ${localMsg.id} - replacing with empty content',
);
// Replace typing indicator with empty content so UI can show loading state
updatedMessages.add(
localMsg.copyWith(content: '', isStreaming: false),
);
} else {
// Keep local message as-is
updatedMessages.add(localMsg);
}
}
}
if (shouldUpdateTitle) {
debugPrint(
'DEBUG: Server generated title: ${updatedConv.title}',
);
// Ensure the title is reasonable (not too long)
final cleanTitle = updatedConv.title.length > 100
? '${updatedConv.title.substring(0, 100)}...'
: updatedConv.title;
// Update the conversation with title and combined messages
final updatedConversation = activeConversation.copyWith(
title: cleanTitle,
messages: updatedMessages, // Use combined messages!
updatedAt: DateTime.now(),
);
ref.read(activeConversationProvider.notifier).state =
updatedConversation;
debugPrint('DEBUG: Conversation title updated successfully');
} else {
// Update just the messages without changing title
final updatedConversation = activeConversation.copyWith(
messages: updatedMessages, // Use combined messages!
updatedAt: DateTime.now(),
);
ref.read(activeConversationProvider.notifier).state =
updatedConversation;
debugPrint(
'DEBUG: Conversation content updated with server response',
);
}
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-10 01:20:45 +05:30
debugPrint(
2025-08-12 13:07:10 +05:30
'DEBUG: Server content replacement completed',
2025-08-10 01:20:45 +05:30
);
2025-08-12 13:07:10 +05:30
// Start background title check for first message exchanges
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
debugPrint('DEBUG: Starting background title check...');
_checkForTitleInBackground(ref, activeConversation.id);
}
2025-08-10 01:20:45 +05:30
} catch (e) {
debugPrint('DEBUG: Failed to fetch server content: $e');
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-10 01:20:45 +05:30
}
} catch (e) {
debugPrint('DEBUG: Chat completed error: $e');
// Continue without failing the entire process
// Note: Conversation still syncs via _saveConversationToServer
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-10 01:20:45 +05:30
}
}
}
// Save conversation to OpenWebUI server only after streaming is complete
debugPrint('DEBUG: About to save conversation to server...');
// Add a small delay to ensure the last message content is fully updated
await Future.delayed(const Duration(milliseconds: 100));
2025-08-12 13:07:10 +05:30
await _saveConversationToServer(ref);
debugPrint('DEBUG: Conversation save completed');
2025-08-10 01:20:45 +05:30
},
onError: (error) {
debugPrint('DEBUG: Stream error in chat provider: $error');
// Mark streaming as complete on error
ref.read(chatMessagesProvider.notifier).finishStreaming();
// Special handling for Socket.IO streaming failures
// These indicate the server generated a response but we couldn't stream it
if (error.toString().contains(
'Socket.IO streaming not fully implemented',
)) {
debugPrint(
'DEBUG: Socket.IO streaming failed, but server may have generated response',
);
debugPrint(
'DEBUG: Keeping assistant message for server content replacement',
);
// Don't remove the message - let the server content replacement handle it
// The onDone callback will fetch the actual response from the server
return; // Exit early to avoid removing the message
}
// Handle streaming error - remove the assistant message placeholder for other errors
ref.read(chatMessagesProvider.notifier).removeLastMessage();
// Handle different types of errors
if (error.toString().contains('400')) {
// Bad request errors - likely malformed request format
debugPrint(
'DEBUG: Bad request error (400) - malformed request format',
);
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'''⚠️ **Message Format Error**
This might be because:
Image attachment couldn't be processed
Request format incompatible with selected model
Message contains unsupported content
2025-08-10 01:20:45 +05:30
**💡 Solutions:**
Long press this message and select "Retry"
Try removing attachments and resending
Switch to a different model and retry
2025-08-10 01:20:45 +05:30
*Long press this message to access retry options.*''',
2025-08-10 01:20:45 +05:30
timestamp: DateTime.now(),
isStreaming: false,
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else if (error.toString().contains('401') ||
error.toString().contains('403')) {
// Authentication errors - clear auth state and redirect to login
ref.invalidate(authStateManagerProvider);
} else if (error.toString().contains('500')) {
// Server errors - add user-friendly error message
debugPrint('DEBUG: Server error (500) - OpenWebUI server issue');
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'''⚠️ **Server Error**
This usually means:
OpenWebUI server is experiencing issues
Selected model might be unavailable
Temporary connection problem
**💡 Solutions:**
Long press this message and select "Retry"
Wait a moment and try again
Switch to a different model
Check with your server administrator
*Long press this message to access retry options.*''',
2025-08-10 01:20:45 +05:30
timestamp: DateTime.now(),
isStreaming: false,
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else if (error.toString().contains('timeout')) {
// Timeout errors
debugPrint('DEBUG: Request timeout error');
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'''⏱️ **Request Timeout**
This might be because:
Server taking too long to respond
Internet connection is slow
Model processing a complex request
**💡 Solutions:**
Long press this message and select "Retry"
Try a shorter message
Check your internet connection
Switch to a faster model
*Long press this message to access retry options.*''',
2025-08-10 01:20:45 +05:30
timestamp: DateTime.now(),
isStreaming: false,
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
}
// Don't throw the error to prevent unhandled exceptions
// The error message has been added to the chat
debugPrint('DEBUG: Chat error handled gracefully: ${error.toString()}');
},
);
// Register the stream subscription for proper cleanup
ref
.read(chatMessagesProvider.notifier)
.setMessageStream(streamSubscription);
} 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')) {
debugPrint('DEBUG: Bad request error (400) during initial request setup');
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('500')) {
debugPrint('DEBUG: Server error (500) during initial request setup');
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')) {
debugPrint('DEBUG: Model or endpoint not found (404)');
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
debugPrint('DEBUG: Unexpected error during chat request: $e');
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);
}
}
}
2025-08-16 17:36:02 +05:30
// Trigger title generation using the dedicated endpoint
Future<void> _triggerTitleGeneration(
dynamic ref,
String conversationId,
List<Map<String, dynamic>> messages,
String model,
) async {
try {
final api = ref.read(apiServiceProvider);
if (api == null) return;
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') {
debugPrint('DEBUG: Title generated successfully: $generatedTitle');
// Update the active conversation with the new title
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation?.id == conversationId) {
final updated = activeConversation!.copyWith(
title: generatedTitle,
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');
final currentMessages = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages(
conversationId,
currentMessages,
title: generatedTitle,
model: model,
);
debugPrint('DEBUG: Title saved to server successfully');
} catch (e) {
debugPrint('DEBUG: Failed to save title to server: $e');
}
// Refresh the conversations list
ref.invalidate(conversationsProvider);
}
} else {
debugPrint('DEBUG: Title generation did not return a valid title');
// Fall back to background checking
_checkForTitleInBackground(ref, conversationId);
}
} catch (e) {
debugPrint('DEBUG: Title generation failed: $e');
// Fall back to background checking
_checkForTitleInBackground(ref, conversationId);
}
}
2025-08-12 13:07:10 +05:30
// Background function to check for title updates without blocking UI
Future<void> _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}');
// Update the active conversation with the new title
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation?.id == conversationId) {
final updated = activeConversation!.copyWith(
title: updatedConv.title,
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)));
}
} catch (e) {
debugPrint('DEBUG: Background title check error: $e');
break; // Stop on error
}
}
debugPrint('DEBUG: Background title check completed without finding generated title');
} catch (e) {
debugPrint('DEBUG: Background title check failed: $e');
}
}
2025-08-10 01:20:45 +05:30
// Save current conversation to OpenWebUI server
Future<void> _saveConversationToServer(dynamic ref) async {
try {
debugPrint('DEBUG: _saveConversationToServer started');
final api = ref.read(apiServiceProvider);
final messages = ref.read(chatMessagesProvider);
final activeConversation = ref.read(activeConversationProvider);
final selectedModel = ref.read(selectedModelProvider);
debugPrint(
'DEBUG: Conversation save state - API: ${api != null}, Messages: ${messages.length}, Active: ${activeConversation?.id}, Model: ${selectedModel?.id}',
);
if (api == null || messages.isEmpty || activeConversation == null) {
debugPrint('DEBUG: Skipping conversation save - missing required data');
return;
}
// Check if the last message (assistant) has content
final lastMessage = messages.last;
if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) {
debugPrint(
'DEBUG: Skipping conversation save - assistant message has no content',
);
return;
}
// Update the existing conversation with all messages (including assistant response)
debugPrint(
'DEBUG: Updating conversation ${activeConversation.id} with complete message history',
);
2025-08-12 13:07:10 +05:30
debugPrint(
'DEBUG: Conversation ID being updated: ${activeConversation.id}',
);
debugPrint(
'DEBUG: Number of messages to save: ${messages.length}',
);
2025-08-10 01:20:45 +05:30
try {
await api.updateConversationWithMessages(
activeConversation.id,
messages,
model: selectedModel?.id,
);
// Update local state
final updatedConversation = activeConversation.copyWith(
messages: messages,
updatedAt: DateTime.now(),
);
ref.read(activeConversationProvider.notifier).state = updatedConversation;
debugPrint(
'DEBUG: Successfully updated conversation on server: ${activeConversation.id}',
);
2025-08-12 13:07:10 +05:30
debugPrint(
'DEBUG: Updated conversation title: ${updatedConversation.title}',
);
2025-08-10 01:20:45 +05:30
} catch (e) {
debugPrint('DEBUG: Failed to update conversation on server: $e');
2025-08-12 13:07:10 +05:30
debugPrint('DEBUG: Error details: $e');
2025-08-10 01:20:45 +05:30
// Fallback to local storage if server update fails
await _saveConversationLocally(ref);
return;
}
// Refresh conversations list to show the updated conversation
debugPrint(
'DEBUG: Invalidating conversations provider after successful save',
);
2025-08-17 16:11:19 +05:30
// Adding a small delay to prevent rapid invalidations that could cause duplicates
Future.delayed(const Duration(milliseconds: 100), () {
ref.invalidate(conversationsProvider);
debugPrint('DEBUG: Conversations provider invalidated');
});
2025-08-10 01:20:45 +05:30
} catch (e) {
debugPrint('Error saving conversation to server: $e');
// Fallback to local storage
await _saveConversationLocally(ref);
}
}
// Fallback: Save current conversation to local storage
Future<void> _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(),
);
2025-08-12 13:07:10 +05:30
// Store conversation locally using the storage service's actual methods
final conversationsJson = await storage.getString('conversations') ?? '[]';
final List<dynamic> 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();
2025-08-10 01:20:45 +05:30
} else {
2025-08-12 13:07:10 +05:30
conversations.add(updatedConversation.toJson());
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
await storage.setString('conversations', jsonEncode(conversations));
ref.read(activeConversationProvider.notifier).state = updatedConversation;
2025-08-10 01:20:45 +05:30
ref.invalidate(conversationsProvider);
} catch (e) {
debugPrint('Error saving conversation locally: $e');
}
}
String _generateConversationTitle(List<ChatMessage> 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<void> 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).state = activeConversation!
.copyWith(pinned: pinned);
}
} catch (e) {
debugPrint('Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e');
rethrow;
}
}
// Archive/Unarchive conversation
Future<void> 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).state = null;
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) {
debugPrint(
'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e',
);
// If server operation failed and we archived locally, restore the conversation
if (activeConversation?.id == conversationId && archived) {
ref.read(activeConversationProvider.notifier).state = activeConversation;
// Messages will be restored through the listener
}
rethrow;
}
}
// Share conversation
Future<String?> 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) {
debugPrint('Error sharing conversation: $e');
rethrow;
}
}
// Clone conversation
Future<void> 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).state = 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) {
debugPrint('Error cloning conversation: $e');
rethrow;
}
}
// Regenerate last message
final regenerateLastMessageProvider = Provider<void Function()>((ref) {
return () async {
final messages = ref.read(chatMessagesProvider);
if (messages.length < 2) return;
// Find last user message with proper bounds checking
ChatMessage? lastUserMessage;
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();
// Resend the message
await _sendMessageInternal(
ref,
lastUserMessage.content,
lastUserMessage.attachmentIds,
);
};
});
// Stop generation provider
final stopGenerationProvider = Provider<void Function()>((ref) {
return () {
// This would need to be implemented with proper cancellation support
// For now, just mark streaming as complete
ref.read(chatMessagesProvider.notifier).finishStreaming();
};
});