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

2589 lines
97 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-31 14:02:44 +05:30
import 'package:yaml/yaml.dart' as yaml;
2025-08-21 19:11:17 +05:30
2025-08-10 01:20:45 +05:30
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
2025-09-05 02:54:59 +05:30
import '../../../core/utils/tool_calls_parser.dart';
2025-08-10 01:20:45 +05:30
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-09-01 16:28:49 +05:30
import '../../../core/utils/debug_logger.dart';
2025-08-17 16:11:19 +05:30
import '../services/reviewer_mode_service.dart';
2025-09-01 23:41:22 +05:30
import '../../../shared/services/tasks/task_queue.dart';
2025-08-10 01:20:45 +05:30
2025-09-01 16:47:41 +05:30
const bool kSocketVerboseLogging = false;
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);
// Prefilled input text (e.g., when sharing text from other apps)
final prefilledInputTextProvider = StateProvider<String?>((ref) => null);
2025-08-28 14:45:46 +05:30
// Trigger to request focus on the chat input (increment to signal)
final inputFocusTriggerProvider = StateProvider<int>((ref) => 0);
2025-08-10 01:20:45 +05:30
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,
) {
2025-08-21 19:11:17 +05:30
debugPrint('Conversation changed: ${previous?.id} -> ${next?.id}');
2025-08-10 01:20:45 +05:30
// 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.
2025-08-10 01:20:45 +05:30
if (previous?.updatedAt != next?.updatedAt) {
final serverMessages = next?.messages ?? const [];
// Only replace local messages if the server has strictly more messages
// (i.e., includes new content we don't have yet).
if (serverMessages.length > state.length) {
state = serverMessages;
}
2025-08-10 01:20:45 +05:30
}
return;
}
// Cancel any existing message stream when switching conversations
_cancelMessageStream();
if (next != null) {
state = next.messages;
2025-08-21 14:37:49 +05:30
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 {
state = [];
}
});
// ProviderSubscription will be cleaned up in dispose method
}
void _addSubscription(StreamSubscription subscription) {
_subscriptions.add(subscription);
}
void _cancelMessageStream() {
_messageStream?.cancel();
_messageStream = null;
}
2025-09-01 20:26:29 +05:30
// Public wrapper to cancel the currently active stream (used by Stop)
void cancelActiveMessageStream() {
_cancelMessageStream();
}
2025-08-17 00:26:12 +05:30
Future<void> _updateModelForConversation(Conversation conversation) async {
// Check if conversation has a model specified
if (conversation.model == null || conversation.model!.isEmpty) {
return;
}
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
final currentSelectedModel = _ref.read(selectedModelProvider);
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// 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);
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
if (models.isEmpty) {
return;
}
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// Look for exact match first
2025-08-21 14:37:49 +05:30
final conversationModel = models
.where((model) => model.id == conversation.model)
.firstOrNull;
2025-08-17 00:26:12 +05:30
if (conversationModel != null) {
// Update the selected model
_ref.read(selectedModelProvider.notifier).state = conversationModel;
} else {
2025-08-21 19:11:17 +05:30
// Model not found in available models - silently continue
2025-08-17 00:26:12 +05:30
}
} catch (e) {
2025-08-21 19:11:17 +05:30
// Model update failed - silently continue
2025-08-17 00:26:12 +05:30
}
}
}
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;
2025-08-31 14:02:44 +05:30
// 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;
}
2025-08-10 01:20:45 +05:30
state = [
...state.sublist(0, state.length - 1),
2025-08-31 14:02:44 +05:30
lastMessage.copyWith(content: sanitized(content)),
2025-08-10 01:20:45 +05:30
];
}
2025-08-21 14:37:49 +05:30
void updateLastMessageWithFunction(
ChatMessage Function(ChatMessage) updater,
) {
2025-08-19 13:09:40 +05:30
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant') return;
2025-08-21 14:37:49 +05:30
state = [...state.sublist(0, state.length - 1), updater(lastMessage)];
2025-08-19 13:09:40 +05:30
}
2025-08-10 01:20:45 +05:30
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;
}
2025-08-10 01:20:45 +05:30
2025-08-31 14:02:44 +05:30
// 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;
2025-08-10 01:20:45 +05:30
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: newContent),
];
}
void replaceLastMessageContent(String content) {
if (state.isEmpty) {
return;
}
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
return;
}
2025-08-31 14:02:44 +05:30
// 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);
}
2025-08-10 01:20:45 +05:30
state = [
...state.sublist(0, state.length - 1),
2025-08-31 14:02:44 +05:30
lastMessage.copyWith(content: sanitized),
2025-08-10 01:20:45 +05:30
];
}
void finishStreaming() {
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return;
2025-08-31 14:02:44 +05:30
// 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);
}
2025-08-10 01:20:45 +05:30
state = [
...state.sublist(0, state.length - 1),
2025-08-31 14:02:44 +05:30
lastMessage.copyWith(isStreaming: false, content: cleaned),
2025-08-10 01:20:45 +05:30
];
}
@override
void dispose() {
debugPrint(
2025-08-21 19:11:17 +05:30
'ChatMessagesNotifier disposing - ${_subscriptions.length} subscriptions',
2025-08-10 01:20:45 +05:30
);
// 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;
super.dispose();
}
}
2025-09-05 11:48:43 +05:30
// 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<String> _preseedAssistantAndPersist(
dynamic ref, {
String? existingAssistantId,
required String modelId,
}) async {
final api = ref.read(apiServiceProvider);
final activeConv = ref.read(activeConversationProvider);
// 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 (_) {}
}
// Persist the skeleton to the server so the web client sees a correct chain
try {
if (api != null && activeConv != null) {
final current = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages(
activeConv.id,
current,
model: modelId,
);
}
} catch (_) {}
return assistantMessageId;
}
2025-08-10 01:20:45 +05:30
// 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).state = null;
// Clear messages
ref.read(chatMessagesProvider.notifier).clearMessages();
}
// Available tools provider
final availableToolsProvider = StateProvider<List<String>>((ref) => []);
// Web search enabled state for API-based web search
final webSearchEnabledProvider = StateProvider<bool>((ref) => false);
2025-08-21 14:37:49 +05:30
// Image generation enabled state - behaves like web search
final imageGenerationEnabledProvider = StateProvider<bool>((ref) => false);
2025-08-10 01:20:45 +05:30
// 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 {
// 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;
}
}
// Regenerate message function that doesn't duplicate user message
Future<void> regenerateMessage(
WidgetRef ref,
String userMessageContent,
List<String>? 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');
}
final 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(),
2025-09-01 18:49:43 +05:30
model: selectedModel.id,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
2025-09-05 02:54:59 +05:30
// Helpers defined above
2025-09-01 16:28:49 +05:30
// Reviewer mode: no immediate tool preview (no tool context)
// Reviewer mode: no immediate tool preview (no tool context)
2025-08-17 16:11:19 +05:30
// Use canned response for regeneration
final responseText = ReviewerModeService.generateResponse(
userMessage: userMessageContent,
);
2025-08-21 14:37:49 +05:30
// 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 ');
}
2025-08-21 14:37:49 +05:30
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);
2025-08-21 14:37:49 +05:30
final List<Map<String, dynamic>> conversationMessages =
<Map<String, dynamic>>[];
for (final msg in messages) {
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
2025-09-05 11:15:39 +05:30
// Clean up tool/details markup to match web client behavior
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
// Handle messages with attachments
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
final List<Map<String, dynamic>> contentArray = [];
2025-08-21 14:37:49 +05:30
// Add text content first
2025-09-05 11:15:39 +05:30
if (cleaned.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleaned});
}
conversationMessages.add({
'role': msg.role,
2025-09-05 11:15:39 +05:30
'content': contentArray.isNotEmpty ? contentArray : cleaned,
});
} else {
// Regular text message
2025-09-05 11:15:39 +05:30
conversationMessages.add({'role': msg.role, 'content': cleaned});
}
}
}
2025-09-05 11:48:43 +05:30
// Pre-seed assistant skeleton and persist chain
final String assistantMessageId = await _preseedAssistantAndPersist(
ref,
modelId: selectedModel.id,
);
2025-09-05 11:15:39 +05:30
// Stream response via background task (socket/dynamic channel or polling)
final response = api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
responseMessageId: assistantMessageId,
);
final stream = response.stream;
2025-08-31 14:02:44 +05:30
// Handle streaming response (basic chunking for this path)
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) {
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 {
await _sendMessageInternal(ref, message, attachments, toolIds);
2025-08-10 01:20:45 +05:30
}
// Service-friendly wrapper (accepts generic Ref)
Future<void> sendMessageFromService(
Ref ref,
String message,
List<String>? attachments, [
List<String>? toolIds,
]) async {
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
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');
}
// Check if we need to create a new conversation first
var activeConversation = ref.read(activeConversationProvider);
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Create user message first
2025-08-21 19:11:17 +05:30
2025-08-12 13:07:10 +05:30
final userMessage = ChatMessage(
id: const Uuid().v4(),
role: 'user',
content: message,
timestamp: DateTime.now(),
2025-09-01 18:49:43 +05:30
model: selectedModel.id,
2025-08-12 13:07:10 +05:30
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
2025-08-10 01:20:45 +05:30
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-21 14:37:49 +05:30
messages: serverConversation.messages.isNotEmpty
? serverConversation.messages
2025-08-12 13:07:10 +05:30
: [userMessage],
2025-08-10 01:20:45 +05:30
);
ref.read(activeConversationProvider.notifier).state =
updatedConversation;
activeConversation = updatedConversation;
2025-08-21 14:37:49 +05:30
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);
2025-08-21 14:37:49 +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), () {
2025-08-28 14:45:46 +05:30
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
}
2025-08-17 16:11:19 +05:30
});
2025-08-10 01:20:45 +05:30
} catch (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);
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)
// Immediately trigger title generation after user message is sent (first turn only)
try {
final currentConversation = ref.read(activeConversationProvider);
if (currentConversation != null &&
currentConversation.title == 'New Chat') {
final currentMessages = ref.read(chatMessagesProvider);
if (currentMessages.length == 1 && currentMessages.first.role == 'user') {
final List<Map<String, dynamic>> formatted = [
{
'id': currentMessages.first.id,
'role': currentMessages.first.role,
'content': currentMessages.first.content,
'timestamp':
currentMessages.first.timestamp.millisecondsSinceEpoch ~/ 1000,
},
];
_triggerTitleGeneration(
ref,
currentConversation.id,
formatted,
selectedModel.id,
);
}
}
} catch (e) {
// Silent fail for early title generation
}
2025-08-10 01:20:45 +05:30
// Reviewer mode: simulate a response locally and return
if (reviewerMode) {
// Add assistant message placeholder
final assistantMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content: '',
2025-08-10 01:20:45 +05:30
timestamp: DateTime.now(),
2025-09-01 18:49:43 +05:30
model: selectedModel.id,
2025-08-10 01:20:45 +05:30
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) {
2025-09-05 11:15:39 +05:30
// Prepare cleaned text content (strip tool details etc.)
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
2025-08-10 01:20:45 +05:30
// Check if message has attachments (images and non-images)
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
2025-08-21 19:11:17 +05:30
// All models use the same content array format (OpenWebUI standard)
2025-08-10 01:20:45 +05:30
// 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
2025-09-05 11:15:39 +05:30
if (cleaned.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleaned});
2025-08-10 01:20:45 +05:30
}
// Add image attachments with proper MIME type handling; collect non-image attachments
for (final attachmentId in msg.attachmentIds!) {
try {
final base64Data = await _getFileAsBase64(api, attachmentId);
if (base64Data != null) {
// Check if this is already a data URL
if (base64Data.startsWith('data:')) {
contentArray.add({
'type': 'image_url',
'image_url': {'url': base64Data},
});
} 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';
}
contentArray.add({
'type': 'image_url',
'image_url': {'url': 'data:$mimeType;base64,$base64Data'},
});
}
}
} else {
// Treat as non-image file; include minimal descriptor so server can resolve by id
nonImageFiles.add({'id': attachmentId, 'type': 'file'});
}
} catch (e) {
2025-08-21 19:11:17 +05:30
// Handle attachment processing errors silently
2025-08-10 01:20:45 +05:30
}
}
final messageMap = <String, dynamic>{
'role': msg.role,
'content': contentArray,
};
if (nonImageFiles.isNotEmpty) {
messageMap['files'] = nonImageFiles;
}
conversationMessages.add(messageMap);
} else {
// Regular text-only message
2025-09-05 11:15:39 +05:30
conversationMessages.add({'role': msg.role, 'content': cleaned});
2025-08-10 01:20:45 +05:30
}
}
}
// Check feature toggles for API (gated by server availability)
final webSearchEnabled =
ref.read(webSearchEnabledProvider) &&
ref.read(webSearchAvailableProvider);
2025-08-21 14:37:49 +05:30
final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider);
2025-08-19 20:26:19 +05:30
// Prepare tools list - pass tool IDs directly
2025-08-21 14:37:49 +05:30
final List<String>? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty)
? toolIds
: null;
2025-08-10 01:20:45 +05:30
try {
2025-09-05 11:15:39 +05:30
// 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);
// Persist skeleton chain to server so web can load correct history
try {
final activeConvForSeed = ref.read(activeConversationProvider);
if (activeConvForSeed != null) {
final msgsForSeed = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages(
activeConvForSeed.id,
msgsForSeed,
model: selectedModel.id,
);
}
} catch (_) {}
2025-08-10 01:20:45 +05:30
// 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': <dynamic>[],
'filters': <dynamic>[],
'tags': <dynamic>[],
};
2025-09-05 02:54:59 +05:30
// Image generation will be handled by server background tools; continue with unified flow
2025-08-21 15:08:57 +05:30
2025-09-05 02:54:59 +05:30
// Define helpers for extracting/attaching image files from tool deltas/content
List<Map<String, dynamic>> _extractFilesFromResult(dynamic resp) {
final results = <Map<String, dynamic>>[];
if (resp == null) return results;
dynamic r = resp;
if (r is String) {
try { r = jsonDecode(r); } catch (_) {}
}
if (r is List) {
for (final item in r) {
if (item is String && item.isNotEmpty) {
results.add({'type': 'image', 'url': item});
} else if (item is Map) {
final url = item['url'];
final b64 = item['b64_json'] ?? item['b64'];
if (url is String && url.isNotEmpty) {
results.add({'type': 'image', 'url': url});
} else if (b64 is String && b64.isNotEmpty) {
results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'});
2025-08-21 15:08:57 +05:30
}
}
2025-09-05 02:54:59 +05:30
}
return results;
}
if (r is! Map) return results;
final data = r['data'];
if (data is List) {
for (final item in data) {
if (item is Map) {
final url = item['url'];
final b64 = item['b64_json'] ?? item['b64'];
if (url is String && url.isNotEmpty) {
results.add({'type': 'image', 'url': url});
} else if (b64 is String && b64.isNotEmpty) {
results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'});
2025-08-21 15:08:57 +05:30
}
2025-09-05 02:54:59 +05:30
} else if (item is String && item.isNotEmpty) {
results.add({'type': 'image', 'url': item});
2025-08-21 15:08:57 +05:30
}
2025-09-05 02:54:59 +05:30
}
}
final images = r['images'];
if (images is List) {
for (final item in images) {
if (item is String && item.isNotEmpty) {
results.add({'type': 'image', 'url': item});
} else if (item is Map) {
final url = item['url'];
final b64 = item['b64_json'] ?? item['b64'];
if (url is String && url.isNotEmpty) {
results.add({'type': 'image', 'url': url});
} else if (b64 is String && b64.isNotEmpty) {
results.add({'type': 'image', 'url': 'data:image/png;base64,$b64'});
2025-08-21 15:08:57 +05:30
}
}
2025-09-05 02:54:59 +05:30
}
}
final files = r['files'];
if (files is List) {
results.addAll(_extractFilesFromResult(files));
}
final singleUrl = r['url'];
if (singleUrl is String && singleUrl.isNotEmpty) {
results.add({'type': 'image', 'url': singleUrl});
}
final singleB64 = r['b64_json'] ?? r['b64'];
if (singleB64 is String && singleB64.isNotEmpty) {
results.add({'type': 'image', 'url': 'data:image/png;base64,$singleB64'});
}
return results;
}
2025-08-21 15:08:57 +05:30
2025-09-05 02:54:59 +05:30
void _updateImagesFromCurrentContent() {
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isEmpty || msgs.last.role != 'assistant') return;
final content = msgs.last.content;
2025-09-05 21:05:58 +05:30
if (content.isEmpty) return;
2025-09-05 02:54:59 +05:30
final collected = <Map<String, dynamic>>[];
2025-09-05 21:05:58 +05:30
// First, try the complete parsing approach
if (content.contains('<details')) {
final parsed = ToolCallsParser.parse(content);
if (parsed != null) {
for (final entry in parsed.toolCalls) {
if (entry.files != null && entry.files!.isNotEmpty) {
collected.addAll(_extractFilesFromResult(entry.files));
}
if (entry.result != null) {
collected.addAll(_extractFilesFromResult(entry.result));
}
}
}
}
// Streaming-friendly: Extract images from partial content
// Look for common image generation patterns even in incomplete blocks
if (collected.isEmpty) {
// Extract base64 images directly from content
final base64Pattern = RegExp(r'data:image/[^;\s]+;base64,[A-Za-z0-9+/]+=*');
final base64Matches = base64Pattern.allMatches(content);
for (final match in base64Matches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
2025-08-21 15:08:57 +05:30
}
2025-09-05 21:05:58 +05:30
// Extract URLs from partial tool call content
final urlPattern = RegExp(r'https?://[^\s<>"]+\.(jpg|jpeg|png|gif|webp)', caseSensitive: false);
final urlMatches = urlPattern.allMatches(content);
for (final match in urlMatches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
// Look for JSON-like structures in streaming content
final jsonPattern = RegExp(r'\{[^}]*"url"[^}]*:[^}]*"(data:image/[^"]+|https?://[^"]+\.(jpg|jpeg|png|gif|webp))"[^}]*\}', caseSensitive: false);
final jsonMatches = jsonPattern.allMatches(content);
for (final match in jsonMatches) {
final url = RegExp(r'"url"[^:]*:[^"]*"([^"]+)"').firstMatch(match.group(0) ?? '')?.group(1);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
// Look for image generation results in partial results/files attributes
final partialResultsPattern = RegExp(r'(result|files)="([^"]*(?:data:image/[^"]*|https?://[^"]*\.(jpg|jpeg|png|gif|webp))[^"]*)"', caseSensitive: false);
final partialMatches = partialResultsPattern.allMatches(content);
for (final match in partialMatches) {
final attrValue = match.group(2);
if (attrValue != null) {
// Try to parse as JSON array or single value
try {
final decoded = json.decode(attrValue);
collected.addAll(_extractFilesFromResult(decoded));
} catch (_) {
// If not JSON, check if it's a direct URL
if (attrValue.startsWith('data:image/') ||
RegExp(r'https?://[^\s]+\.(jpg|jpeg|png|gif|webp)$', caseSensitive: false).hasMatch(attrValue)) {
collected.add({'type': 'image', 'url': attrValue});
}
}
}
2025-08-21 15:08:57 +05:30
}
}
2025-09-05 21:05:58 +05:30
2025-09-05 02:54:59 +05:30
if (collected.isEmpty) return;
2025-09-05 21:05:58 +05:30
2025-09-05 02:54:59 +05:30
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
final seen = <String>{
for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '',
}..removeWhere((e) => e.isEmpty);
2025-09-05 21:05:58 +05:30
2025-09-05 02:54:59 +05:30
final merged = <Map<String, dynamic>>[...existing];
for (final f in collected) {
final url = f['url'] as String?;
if (url != null && url.isNotEmpty && !seen.contains(url)) {
merged.add({'type': 'image', 'url': url});
seen.add(url);
2025-08-21 15:45:07 +05:30
}
2025-08-21 15:08:57 +05:30
}
2025-09-05 21:05:58 +05:30
2025-09-05 02:54:59 +05:30
if (merged.length != existing.length) {
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
(m) => m.copyWith(files: merged),
);
}
} catch (_) {}
2025-08-21 15:08:57 +05:30
}
2025-09-02 21:19:07 +05:30
// Stream response using server-push via Socket when available, otherwise fallback
2025-08-31 14:02:44 +05:30
// Resolve Socket session for background tasks parity
final socketService = ref.read(socketServiceProvider);
final socketSessionId = socketService?.sessionId;
2025-09-02 21:19:07 +05:30
final bool wantSessionBinding =
(socketService?.isConnected == true) &&
(socketSessionId != null && socketSessionId.isNotEmpty);
2025-08-31 14:02:44 +05:30
// Resolve tool servers from user settings (if any)
List<Map<String, dynamic>>? toolServers;
try {
final userSettings = await api.getUserSettings();
final ui = userSettings['ui'] as Map<String, dynamic>?;
final rawServers = ui != null ? (ui['toolServers'] as List?) : null;
if (rawServers != null && rawServers.isNotEmpty) {
toolServers = await _resolveToolServers(rawServers, api);
}
} catch (_) {}
// Background tasks parity with Web client (safe defaults)
2025-09-05 11:20:39 +05:30
// Enable title/tags generation on the very first user turn of a new chat.
2025-09-05 02:54:59 +05:30
bool shouldGenerateTitle = false;
try {
final conv = ref.read(activeConversationProvider);
2025-09-05 11:20:39 +05:30
// Use the outbound conversationMessages we just built (excludes streaming placeholders)
final nonSystemCount = conversationMessages
.where((m) => (m['role']?.toString() ?? '') != 'system')
.length;
2025-09-05 02:54:59 +05:30
shouldGenerateTitle = (conv == null) ||
2025-09-05 11:20:39 +05:30
((conv.title == 'New Chat' || (conv.title.isEmpty)) && nonSystemCount == 1);
2025-09-05 02:54:59 +05:30
} catch (_) {}
2025-09-05 11:15:39 +05:30
// Match web client: request background follow-ups always; title/tags on first turn
2025-08-31 14:02:44 +05:30
final bgTasks = <String, dynamic>{
2025-09-05 02:54:59 +05:30
if (shouldGenerateTitle) 'title_generation': true,
if (shouldGenerateTitle) 'tags_generation': true,
2025-08-31 14:02:44 +05:30
'follow_up_generation': true,
2025-09-05 11:15:39 +05:30
if (webSearchEnabled) 'web_search': true, // enable bg web search
2025-09-05 02:54:59 +05:30
if (imageGenerationEnabled) 'image_generation': true, // enable bg image flow
2025-08-31 14:02:44 +05:30
};
// Determine if we need background task flow (tools/tool servers or web search)
2025-09-01 23:41:22 +05:30
final bool isBackgroundToolsFlowPre =
(toolIdsForApi != null && toolIdsForApi.isNotEmpty) ||
(toolServers != null && toolServers.isNotEmpty);
final bool isBackgroundWebSearchPre = webSearchEnabled;
2025-09-01 23:41:22 +05:30
2025-08-16 17:36:02 +05:30
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,
2025-09-05 02:54:59 +05:30
// Enable image generation on the server when requested
enableImageGeneration: imageGenerationEnabled,
2025-08-10 01:20:45 +05:30
modelItem: modelItem,
2025-09-02 21:19:07 +05:30
// Bind to Socket session whenever available so the server can push
// streaming updates to this client (improves first-turn streaming).
sessionIdOverride: wantSessionBinding ? socketSessionId : null,
2025-08-31 14:02:44 +05:30
toolServers: toolServers,
backgroundTasks: bgTasks,
2025-09-05 11:15:39 +05:30
responseMessageId: assistantMessageId,
2025-08-10 01:20:45 +05:30
);
final stream = response.stream;
final sessionId = response.sessionId;
2025-08-31 14:02:44 +05:30
// If socket is available, start listening for chat-events immediately
2025-09-02 21:19:07 +05:30
// Background-tools flow OR any session-bound flow relies on socket/dynamic channel for
// streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress
2025-09-01 17:41:55 +05:30
// socket TEXT to avoid duplicates (still surface tool_call status).
final bool isBackgroundFlow =
isBackgroundToolsFlowPre || isBackgroundWebSearchPre || wantSessionBinding;
2025-09-02 21:19:07 +05:30
bool suppressSocketContent = !isBackgroundFlow; // allow socket text when session-bound or tools
2025-09-01 17:34:05 +05:30
bool usingDynamicChannel = false; // set true when server provides a channel
2025-08-31 14:02:44 +05:30
if (socketService != null) {
void chatHandler(Map<String, dynamic> ev) {
try {
final data = ev['data'];
if (data == null) return;
final type = data['type'];
final payload = data['data'];
2025-09-01 16:28:49 +05:30
DebugLogger.stream('Socket chat-events: type=$type');
2025-08-31 14:02:44 +05:30
if (type == 'chat:completion' && payload != null) {
if (payload is Map<String, dynamic>) {
// Provider may emit tool_calls at the top level
2025-09-01 16:28:49 +05:30
// Always surface tool_calls status from socket for instant tiles
if (payload.containsKey('tool_calls')) {
final tc = payload['tool_calls'];
if (tc is List) {
for (final call in tc) {
if (call is Map<String, dynamic>) {
final fn = call['function'];
final name = (fn is Map && fn['name'] is String) ? fn['name'] as String : null;
if (name is String && name.isNotEmpty) {
2025-09-01 16:47:41 +05:30
final msgs = ref.read(chatMessagesProvider);
final exists = (msgs.isNotEmpty) && RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + RegExp.escape(name) + r'\"',
multiLine: true,
).hasMatch(msgs.last.content);
if (!exists) {
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
}
}
}
}
}
}
if (!suppressSocketContent && payload.containsKey('choices')) {
2025-08-31 14:02:44 +05:30
final choices = payload['choices'];
if (choices is List && choices.isNotEmpty) {
final choice = choices.first;
final delta = choice is Map ? choice['delta'] : null;
if (delta is Map) {
// Surface tool_calls status like SSE path
if (delta.containsKey('tool_calls')) {
final tc = delta['tool_calls'];
if (tc is List) {
for (final call in tc) {
if (call is Map<String, dynamic>) {
final fn = call['function'];
final name = (fn is Map && fn['name'] is String) ? fn['name'] as String : null;
if (name is String && name.isNotEmpty) {
2025-09-01 16:47:41 +05:30
final msgs = ref.read(chatMessagesProvider);
final exists = (msgs.isNotEmpty) && RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + RegExp.escape(name) + r'\"',
multiLine: true,
).hasMatch(msgs.last.content);
if (!exists) {
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
}
}
}
}
}
}
final content = delta['content']?.toString() ?? '';
if (content.isNotEmpty) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(content);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
}
2025-08-31 14:02:44 +05:30
}
}
}
if (!suppressSocketContent && payload.containsKey('content')) {
2025-08-31 14:02:44 +05:30
final content = payload['content']?.toString() ?? '';
if (content.isNotEmpty) {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final prev = msgs.last.content;
if (prev.isEmpty || prev == '[TYPING_INDICATOR]') {
2025-08-31 14:02:44 +05:30
ref
.read(chatMessagesProvider.notifier)
.replaceLastMessageContent(content);
} else if (content.startsWith(prev)) {
ref
.read(chatMessagesProvider.notifier)
.appendToLastMessage(content.substring(prev.length));
} else {
ref
.read(chatMessagesProvider.notifier)
.replaceLastMessageContent(content);
}
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-08-31 14:02:44 +05:30
} else {
ref
.read(chatMessagesProvider.notifier)
.appendToLastMessage(content);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-08-31 14:02:44 +05:30
}
}
}
if (payload['done'] == true) {
2025-09-01 16:28:49 +05:30
// Stop listening to further socket events for this session.
try { socketService.offChatEvents(); } catch (_) {}
2025-09-01 16:28:49 +05:30
2025-09-01 16:47:41 +05:30
// Notify server that chat is completed (mirrors web client)
try {
final apiSvc = ref.read(apiServiceProvider);
final chatId = activeConversation?.id ?? '';
if (apiSvc != null && chatId.isNotEmpty) {
unawaited(apiSvc
.sendChatCompleted(
chatId: chatId,
messageId: assistantMessageId,
messages: const [],
model: selectedModel.id,
modelItem: modelItem,
sessionId: sessionId,
)
.timeout(const Duration(seconds: 3))
.catchError((_) {}));
}
} catch (_) {}
2025-09-01 16:28:49 +05:30
// If no content was rendered yet, fetch final assistant message from server
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final lastContent = msgs.last.content.trim();
if (lastContent.isEmpty) {
final apiSvc = ref.read(apiServiceProvider);
final chatId = activeConversation?.id;
final msgId = assistantMessageId;
if (apiSvc != null && chatId != null && chatId.isNotEmpty) {
Future.microtask(() async {
try {
2025-09-02 00:04:21 +05:30
final resp = await apiSvc.dio.get('/api/v1/chats/$chatId');
2025-09-01 16:28:49 +05:30
final data = resp.data as Map<String, dynamic>;
String content = '';
final chatObj = data['chat'] as Map<String, dynamic>?;
if (chatObj != null) {
// Prefer chat.messages list
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['text']?.toString() ?? '';
}
}
}
}
// Fallback to history map
if (content.isEmpty) {
final history = chatObj['history'];
if (history is Map && history['messages'] is Map) {
final Map<String, dynamic> messagesMap =
(history['messages'] as Map).cast<String, dynamic>();
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['text']?.toString() ?? '';
}
}
}
}
}
}
if (content.isNotEmpty) {
ref
.read(chatMessagesProvider.notifier)
.replaceLastMessageContent(content);
}
} catch (_) {
// Swallow; we'll still finish streaming
} finally {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
});
return; // Defer finish to microtask
}
}
}
// Normal path: finish now
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
}
} else if (type == 'request:chat:completion' && payload != null) {
// Mirror web client's execute path: listen on provided dynamic channel
final channel = payload['channel'];
if (channel is String && channel.isNotEmpty) {
2025-09-01 17:33:44 +05:30
// Prefer dynamic channel for streaming content; suppress chat-events text to avoid duplicates
suppressSocketContent = true;
2025-09-01 17:34:05 +05:30
usingDynamicChannel = true;
2025-09-01 17:41:55 +05:30
usingDynamicChannel = true;
2025-09-01 17:33:44 +05:30
if (kSocketVerboseLogging) {
DebugLogger.stream('Socket request:chat:completion channel=$channel');
}
2025-09-01 16:28:49 +05:30
void channelLineHandler(dynamic line) {
try {
if (line is String) {
final s = line.trim();
2025-09-02 00:04:21 +05:30
DebugLogger.stream('Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}' : s}');
2025-09-01 16:28:49 +05:30
if (s == '[DONE]' || s == 'DONE') {
socketService.offEvent(channel);
// Channel completed
2025-09-01 16:47:41 +05:30
try {
unawaited(api.sendChatCompleted(
chatId: activeConversation?.id ?? '',
messageId: assistantMessageId,
messages: const [],
model: selectedModel.id,
modelItem: modelItem,
sessionId: sessionId,
));
} catch (_) {}
2025-09-01 16:28:49 +05:30
ref.read(chatMessagesProvider.notifier).finishStreaming();
return;
}
if (s.startsWith('data:')) {
final dataStr = s.substring(5).trim();
if (dataStr == '[DONE]') {
socketService.offEvent(channel);
2025-09-01 16:47:41 +05:30
try {
unawaited(api.sendChatCompleted(
chatId: activeConversation?.id ?? '',
messageId: assistantMessageId,
messages: const [],
model: selectedModel.id,
modelItem: modelItem,
sessionId: sessionId,
));
} catch (_) {}
2025-09-01 16:28:49 +05:30
ref.read(chatMessagesProvider.notifier).finishStreaming();
return;
}
// Try to parse OpenAI-style delta JSON
try {
final Map<String, dynamic> j = jsonDecode(dataStr);
final choices = j['choices'];
if (choices is List && choices.isNotEmpty) {
final choice = choices.first;
final delta = choice is Map ? choice['delta'] : null;
if (delta is Map) {
if (delta.containsKey('content')) {
final c = delta['content']?.toString() ?? '';
if (c.isNotEmpty) {
2025-09-02 00:04:21 +05:30
DebugLogger.stream('Socket [$channel] delta.content len=${c.length}');
2025-09-01 16:28:49 +05:30
}
}
// Surface tool_calls status
if (delta.containsKey('tool_calls')) {
2025-09-01 16:47:41 +05:30
if (kSocketVerboseLogging) {
2025-09-02 00:04:21 +05:30
DebugLogger.stream('Socket [$channel] delta.tool_calls detected');
2025-09-01 16:47:41 +05:30
}
2025-09-01 16:28:49 +05:30
final tc = delta['tool_calls'];
if (tc is List) {
for (final call in tc) {
if (call is Map<String, dynamic>) {
final fn = call['function'];
final name = (fn is Map && fn['name'] is String)
? fn['name'] as String
: null;
if (name is String && name.isNotEmpty) {
2025-09-01 16:47:41 +05:30
final msgs = ref.read(chatMessagesProvider);
final exists = (msgs.isNotEmpty) && RegExp(
r'<details\\s+type=\"tool_calls\"[^>]*\\bname=\"' + RegExp.escape(name) + r'\"',
multiLine: true,
).hasMatch(msgs.last.content);
if (!exists) {
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
}
2025-09-01 16:28:49 +05:30
}
}
}
}
}
// Append streamed content
final content = delta['content']?.toString() ?? '';
if (content.isNotEmpty) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(content);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-09-01 16:28:49 +05:30
}
}
}
} catch (_) {
// Non-JSON line: append as-is
if (s.isNotEmpty) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(s);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-09-01 16:28:49 +05:30
}
}
} else {
// Plain text line
if (s.isNotEmpty) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(s);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-09-01 16:28:49 +05:30
}
}
} else if (line is Map) {
// If server sends { done: true } via channel
final done = line['done'] == true;
if (done) {
socketService.offEvent(channel);
2025-09-01 16:47:41 +05:30
try {
unawaited(api.sendChatCompleted(
chatId: activeConversation?.id ?? '',
messageId: assistantMessageId,
messages: const [],
model: selectedModel.id,
modelItem: modelItem,
sessionId: sessionId,
));
} catch (_) {}
2025-09-01 16:28:49 +05:30
ref.read(chatMessagesProvider.notifier).finishStreaming();
return;
}
}
} catch (_) {}
2025-08-31 14:02:44 +05:30
}
2025-09-01 16:28:49 +05:30
// Register dynamic channel listener
try {
socketService.onEvent(channel, channelLineHandler);
} catch (_) {}
2025-08-31 14:02:44 +05:30
}
2025-09-01 16:28:49 +05:30
} else if (type == 'execute:tool' && payload != null) {
// Show an executing tile immediately using provided tool info
try {
final name = payload['name']?.toString() ?? 'tool';
2025-09-02 00:04:21 +05:30
DebugLogger.stream('Socket execute:tool name=$name');
2025-09-01 16:28:49 +05:30
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
2025-09-05 02:54:59 +05:30
// If tool payload already carries files/result, try to extract images for grid
try {
final files = _extractFilesFromResult(payload['files']);
final resultFiles = _extractFilesFromResult(payload['result']);
final all = [...files, ...resultFiles];
if (all.isNotEmpty) {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
final seen = <String>{
for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '',
}..removeWhere((e) => e.isEmpty);
final merged = <Map<String, dynamic>>[...existing];
for (final f in all) {
final url = f['url'] as String?;
if (url != null && url.isNotEmpty && !seen.contains(url)) {
merged.add({'type': 'image', 'url': url});
seen.add(url);
}
}
if (merged.length != existing.length) {
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
(m) => m.copyWith(files: merged),
);
}
}
}
} catch (_) {}
2025-09-01 16:28:49 +05:30
} catch (_) {}
2025-09-05 21:05:58 +05:30
} else if (type == 'files' && payload != null) {
// Handle files event from socket (image generation results)
try {
DebugLogger.stream('Socket files event received: ${payload.toString()}');
final files = _extractFilesFromResult(payload);
if (files.isNotEmpty) {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
final seen = <String>{
for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '',
}..removeWhere((e) => e.isEmpty);
final merged = <Map<String, dynamic>>[...existing];
for (final f in files) {
final url = f['url'] as String?;
if (url != null && url.isNotEmpty && !seen.contains(url)) {
merged.add({'type': 'image', 'url': url});
seen.add(url);
}
}
if (merged.length != existing.length) {
DebugLogger.stream('Socket files: Adding ${merged.length - existing.length} new images');
final updatedMessage = ref.read(chatMessagesProvider).last.copyWith(files: merged);
DebugLogger.stream('Socket files: Updated message files count: ${updatedMessage.files?.length}');
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
(ChatMessage m) => m.copyWith(files: merged),
);
}
}
}
} catch (e) {
DebugLogger.stream('Socket files event error: $e');
}
2025-08-31 14:02:44 +05:30
}
} catch (_) {}
}
socketService.onChatEvents(chatHandler);
2025-09-01 16:28:49 +05:30
// Also mirror channel-events like the web client
void channelEventsHandler(Map<String, dynamic> ev) {
try {
final data = ev['data'];
if (data == null) return;
final type = data['type'];
final payload = data['data'];
2025-09-02 00:04:21 +05:30
DebugLogger.stream('Socket channel-events: type=$type');
2025-09-01 16:28:49 +05:30
// Handle generic channel progress messages if needed
if (type == 'message' && payload is Map) {
final content = payload['content']?.toString() ?? '';
if (content.isNotEmpty) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(content);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-09-01 16:28:49 +05:30
}
}
} catch (_) {}
}
socketService.onChannelEvents(channelEventsHandler);
2025-08-31 14:02:44 +05:30
Future.delayed(const Duration(seconds: 90), () {
try {
socketService.offChatEvents();
2025-09-01 16:28:49 +05:30
socketService.offChannelEvents();
2025-08-31 14:02:44 +05:30
} catch (_) {}
});
}
2025-09-05 02:54:59 +05:30
// Prepare streaming and background handling
2025-08-10 01:20:45 +05:30
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();
2025-08-21 14:37:49 +05:30
// Register stream with persistent service for app lifecycle handling
final persistentService = PersistentStreamingService();
2025-08-21 15:08:57 +05:30
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-09-05 02:54:59 +05:30
// Image generation handled server-side via tools; no client pre-request
2025-08-21 15:08:57 +05:30
// 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.
// (moved above) streaming registration is already set up
2025-08-19 13:09:40 +05:30
// Track web search status
bool isSearching = false;
2025-08-21 14:37:49 +05:30
2025-09-05 02:54:59 +05:30
// Helpers were defined above
final streamSubscription = persistentController.stream.listen(
2025-08-10 01:20:45 +05:30
(chunk) {
2025-08-31 14:02:44 +05:30
var effectiveChunk = 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
2025-08-21 14:37:49 +05:30
if (chunk.contains('[SEARCHING]') ||
chunk.contains('Searching the web') ||
2025-08-19 13:09:40 +05:30
chunk.contains('web search')) {
isSearching = true;
// Update the message to show search status
2025-08-21 14:37:49 +05:30
ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(
(message) => message.copyWith(
content: '🔍 Searching the web...',
metadata: {'webSearchActive': true},
),
);
2025-08-19 13:09:40 +05:30
return; // Don't append this chunk
}
}
2025-08-21 14:37:49 +05:30
2025-08-19 13:09:40 +05:30
// Check if web search is complete
2025-08-21 14:37:49 +05:30
if (isSearching &&
(chunk.contains('[/SEARCHING]') ||
chunk.contains('Search complete'))) {
2025-08-19 13:09:40 +05:30
isSearching = false;
2025-08-31 14:02:44 +05:30
// Only update metadata; keep content to avoid flicker/indicator reappearing
2025-08-21 14:37:49 +05:30
ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(
(message) => message.copyWith(
metadata: {'webSearchActive': false},
),
);
2025-08-31 14:02:44 +05:30
// Strip markers from this chunk and continue processing
effectiveChunk = effectiveChunk
.replaceAll('[SEARCHING]', '')
.replaceAll('[/SEARCHING]', '');
2025-08-19 13:09:40 +05:30
}
2025-08-21 14:37:49 +05:30
2025-08-31 14:02:44 +05:30
// Regular content - append to message (markers removed above)
if (effectiveChunk.trim().isNotEmpty) {
ref
.read(chatMessagesProvider.notifier)
.appendToLastMessage(effectiveChunk);
2025-09-05 02:54:59 +05:30
_updateImagesFromCurrentContent();
2025-08-19 13:09:40 +05:30
}
2025-08-10 01:20:45 +05:30
},
onDone: () async {
// Unregister from persistent service
persistentService.unregisterStream(streamId);
// Stop socket events now that streaming finished only for SSE-driven streams
if (socketService != null && suppressSocketContent == true) {
try { socketService.offChatEvents(); } catch (_) {}
}
// Allow socket content again for future sessions (harmless if already false)
suppressSocketContent = false;
2025-09-01 17:41:55 +05:30
// If this path was SSE-driven (no background tools/dynamic channel), finish now.
2025-09-01 16:28:49 +05:30
// Otherwise keep streaming state until socket/dynamic channel signals done.
2025-09-02 21:19:07 +05:30
if (!usingDynamicChannel && !isBackgroundFlow) {
2025-09-01 16:28:49 +05:30
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;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// 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);
}
2025-09-01 17:41:55 +05:30
// Only notify completion immediately for non-background SSE flows.
// For background tools/dynamic-channel flows, defer completion
// until the socket/dynamic channel signals done.
2025-09-02 21:19:07 +05:30
if (!isBackgroundFlow && !usingDynamicChannel) {
2025-09-01 17:41:55 +05:30
try {
unawaited(
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
)
.timeout(const Duration(seconds: 3))
.catchError((_) {}),
);
} catch (_) {
// Ignore
}
2025-08-10 01:20:45 +05:30
}
// Fetch the latest conversation state
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
// 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-21 14:37:49 +05:30
2025-08-10 01:20:45 +05:30
if (shouldUpdateTitle) {
// 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,
updatedAt: DateTime.now(),
);
ref.read(activeConversationProvider.notifier).state =
updatedConversation;
} else {
// Keep local messages and only refresh conversations list
ref.invalidate(conversationsProvider);
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
// Removed post-assistant title trigger/background check; handled right after user message
2025-08-10 01:20:45 +05:30
} catch (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) {
// 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
}
}
}
2025-09-05 11:15:39 +05:30
// Do not persist conversation to server here. Server manages chat state.
// Keep local save only for quick resume.
await Future.delayed(const Duration(milliseconds: 50));
await _saveConversationLocally(ref);
2025-08-21 14:37:49 +05:30
// Removed post-assistant image generation; images are handled immediately after user message
2025-08-10 01:20:45 +05:30
},
onError: (error) {
// Mark streaming as complete on error
ref.read(chatMessagesProvider.notifier).finishStreaming();
// Stop socket events to avoid duplicates after error (only for SSE-driven)
if (socketService != null && suppressSocketContent == true) {
try { socketService.offChatEvents(); } catch (_) {}
}
2025-08-10 01:20:45 +05:30
// 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',
)) {
// 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
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
2025-08-21 14:37:49 +05:30
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
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
2025-08-21 14:37:49 +05:30
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
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
2025-08-21 14:37:49 +05:30
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
},
);
// 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')) {
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')) {
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
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 {
2025-09-02 13:20:02 +05:30
// Enqueue background title generation task
2025-08-16 17:36:02 +05:30
try {
2025-09-02 13:20:02 +05:30
await ref
.read(taskQueueProvider.notifier)
.enqueueGenerateTitle(conversationId: conversationId);
} catch (_) {
// Best effort background check remains
2025-08-16 17:36:02 +05:30
_checkForTitleInBackground(ref, conversationId);
}
}
2025-08-12 13:07:10 +05:30
// Background function to check for title updates without blocking UI
2025-08-21 14:37:49 +05:30
Future<void> _checkForTitleInBackground(
dynamic ref,
String conversationId,
) async {
2025-08-12 13:07:10 +05:30
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));
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Try a few times with increasing delays
for (int i = 0; i < 3; i++) {
try {
final updatedConv = await api.getConversation(conversationId);
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
// 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;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Refresh the conversations list
ref.invalidate(conversationsProvider);
}
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
return; // Title found, stop checking
}
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Wait before next check (3s, 5s, 7s)
if (i < 2) {
await Future.delayed(Duration(seconds: 2 + (i * 2)));
}
} catch (e) {
break; // Stop on error
}
}
} catch (e) {
2025-08-21 19:11:17 +05:30
// Handle background title check errors silently
2025-08-12 13:07:10 +05:30
}
}
2025-08-10 01:20:45 +05:30
// Save current conversation to OpenWebUI server
2025-09-05 11:15:39 +05:30
// Removed server persistence; only local caching is used in mobile app.
2025-08-10 01:20:45 +05:30
// 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);
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Find and update or add the conversation
2025-08-21 14:37:49 +05:30
final existingIndex = conversations.indexWhere(
(c) => c['id'] == updatedConversation.id,
);
2025-08-12 13:07:10 +05:30
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-21 14:37:49 +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) {
2025-08-21 19:11:17 +05:30
// Handle local storage errors silently
2025-08-10 01:20:45 +05:30
}
}
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
2025-08-21 16:19:21 +05:30
final regenerateLastMessageProvider = Provider<Future<void> Function()>((ref) {
2025-08-10 01:20:45 +05:30
return () async {
final messages = ref.read(chatMessagesProvider);
if (messages.length < 2) return;
// Find last user message with proper bounds checking
ChatMessage? lastUserMessage;
2025-08-21 15:45:07 +05:30
// 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);
2025-08-10 01:20:45 +05:30
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();
2025-08-21 15:45:07 +05:30
// If previous assistant was image-only or had images, regenerate images instead of text
if (lastAssistantHadImages) {
2025-09-05 02:54:59 +05:30
final prev = ref.read(imageGenerationEnabledProvider);
2025-08-21 15:45:07 +05:30
try {
2025-09-05 02:54:59 +05:30
ref.read(imageGenerationEnabledProvider.notifier).state = true;
final activeConv = ref.read(activeConversationProvider);
await ref.read(taskQueueProvider.notifier).enqueueSendText(
conversationId: activeConv?.id,
text: lastUserMessage.content,
attachments: lastUserMessage.attachmentIds,
2025-08-21 15:45:07 +05:30
);
2025-09-05 02:54:59 +05:30
} finally {
// restore previous state
ref.read(imageGenerationEnabledProvider.notifier).state = prev;
2025-08-21 15:45:07 +05:30
}
return;
}
// Resend the message via task queue (unified flow)
final activeConv = ref.read(activeConversationProvider);
await ref.read(taskQueueProvider.notifier).enqueueSendText(
conversationId: activeConv?.id,
text: lastUserMessage.content,
attachments: lastUserMessage.attachmentIds,
);
2025-08-10 01:20:45 +05:30
};
});
// Stop generation provider
final stopGenerationProvider = Provider<void Function()>((ref) {
return () {
2025-09-01 20:26:29 +05:30
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);
// Stop any active socket listeners for chat/channel events
try {
final socketService = ref.read(socketServiceProvider);
socketService?.offChatEvents();
socketService?.offChannelEvents();
} catch (_) {}
// 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 (_) {}
}());
2025-09-01 23:41:22 +05:30
// 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 (_) {}
2025-09-01 20:26:29 +05:30
}
} catch (_) {}
// Ensure UI transitions out of streaming state
2025-08-10 01:20:45 +05:30
ref.read(chatMessagesProvider.notifier).finishStreaming();
};
});
2025-08-31 14:02:44 +05:30
// ========== Tool Servers (OpenAPI) Helpers ==========
Future<List<Map<String, dynamic>>> _resolveToolServers(
List rawServers,
dynamic api,
) async {
final List<Map<String, dynamic>> 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<String, dynamic>? 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<String, dynamic>;
} else {
final data = resp.data;
if (data is Map<String, dynamic>) {
openapi = data;
} else if (data is String) {
openapi = json.decode(data) as Map<String, dynamic>;
}
}
} 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<String, dynamic>? _resolveRef(String ref, Map<String, dynamic>? 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<String, dynamic>) {
final schema = section[name];
if (schema is Map<String, dynamic>) return Map<String, dynamic>.from(schema);
}
return null;
}
Map<String, dynamic> _resolveSchemaSimple(
dynamic schema,
Map<String, dynamic>? components,
) {
if (schema is Map<String, dynamic>) {
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 = <String, dynamic>{};
if (type is String) {
out['type'] = type;
if (schema['description'] != null) out['description'] = schema['description'];
if (type == 'object') {
out['properties'] = <String, dynamic>{};
if (schema['required'] is List) out['required'] = List.from(schema['required']);
final props = schema['properties'];
if (props is Map<String, dynamic>) {
props.forEach((k, v) {
out['properties'][k] = _resolveSchemaSimple(v, components);
});
}
} else if (type == 'array') {
out['items'] = _resolveSchemaSimple(schema['items'], components);
}
}
return out;
}
return <String, dynamic>{};
}
List<Map<String, dynamic>> _convertOpenApiToToolPayload(Map<String, dynamic> openApi) {
final tools = <Map<String, dynamic>>[];
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 = <String, dynamic>{
'name': operation['operationId'],
'description': operation['description'] ?? operation['summary'] ?? 'No description available.',
'parameters': {
'type': 'object',
'properties': <String, dynamic>{},
'required': <dynamic>[],
},
};
// 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<String, dynamic>?);
if (resolved['properties'] is Map) {
tool['parameters']['properties'] = {
...tool['parameters']['properties'],
...resolved['properties'] as Map<String, dynamic>,
};
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;
}