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

2436 lines
81 KiB
Dart
Raw Normal View History

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-09-26 01:38:00 +05:30
import 'package:flutter/foundation.dart';
2025-08-10 01:20:45 +05:30
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-09-07 21:41:13 +05:30
import '../../../core/services/streaming_helper.dart';
2025-09-26 01:38:00 +05:30
import '../../../core/services/socket_service.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';
2025-09-07 23:17:26 +05:30
import '../../../core/utils/inactivity_watchdog.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-09-07 21:41:13 +05:30
import '../../tools/providers/tools_providers.dart';
import 'dart:async';
2025-09-25 22:36:42 +05:30
import '../../../core/utils/debug_logger.dart';
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 =
2025-09-21 22:31:44 +05:30
NotifierProvider<ChatMessagesNotifier, List<ChatMessage>>(
ChatMessagesNotifier.new,
);
2025-08-10 01:20:45 +05:30
// Loading state for conversation (used to show chat skeletons during fetch)
2025-09-21 22:31:44 +05:30
final isLoadingConversationProvider =
NotifierProvider<IsLoadingConversationNotifier, bool>(
IsLoadingConversationNotifier.new,
);
2025-08-10 01:20:45 +05:30
// Prefilled input text (e.g., when sharing text from other apps)
2025-09-21 22:31:44 +05:30
final prefilledInputTextProvider =
NotifierProvider<PrefilledInputTextNotifier, String?>(
PrefilledInputTextNotifier.new,
);
2025-08-28 14:45:46 +05:30
// Trigger to request focus on the chat input (increment to signal)
2025-09-21 22:31:44 +05:30
final inputFocusTriggerProvider =
NotifierProvider<InputFocusTriggerNotifier, int>(
InputFocusTriggerNotifier.new,
);
2025-08-28 14:45:46 +05:30
2025-09-08 01:15:31 +05:30
// Whether the chat composer currently has focus
2025-09-21 22:31:44 +05:30
final composerHasFocusProvider = NotifierProvider<ComposerFocusNotifier, bool>(
ComposerFocusNotifier.new,
);
class IsLoadingConversationNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
class PrefilledInputTextNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) => state = value;
void clear() => state = null;
}
class InputFocusTriggerNotifier extends Notifier<int> {
@override
int build() => 0;
void set(int value) => state = value;
int increment() {
final next = state + 1;
state = next;
return next;
}
}
class ComposerFocusNotifier extends Notifier<bool> {
@override
bool build() => false;
2025-09-08 01:15:31 +05:30
2025-09-21 22:31:44 +05:30
void set(bool value) => state = value;
}
class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
2025-08-10 01:20:45 +05:30
StreamSubscription? _messageStream;
ProviderSubscription? _conversationListener;
final List<StreamSubscription> _subscriptions = [];
2025-09-26 01:38:00 +05:30
final List<SocketEventSubscription> _socketSubscriptions = [];
VoidCallback? _socketTeardown;
2025-09-07 23:17:26 +05:30
// Activity-based watchdog to prevent stuck typing indicator
InactivityWatchdog? _typingWatchdog;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
bool _initialized = false;
2025-09-21 22:31:44 +05:30
@override
List<ChatMessage> build() {
if (!_initialized) {
_initialized = true;
_conversationListener = ref.listen(activeConversationProvider, (
previous,
next,
) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'Conversation changed: ${previous?.id} -> ${next?.id}',
scope: 'chat/providers',
);
2025-09-21 22:31:44 +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.
if (previous?.updatedAt != next?.updatedAt) {
final serverMessages = next?.messages ?? const [];
// Primary rule: adopt server messages when there are strictly more of them.
if (serverMessages.length > state.length) {
state = serverMessages;
return;
}
2025-09-21 22:31:44 +05:30
// Secondary rule: if counts are equal but the last assistant message grew,
// adopt the server copy to recover from missed socket events.
if (serverMessages.isNotEmpty && state.isNotEmpty) {
final serverLast = serverMessages.last;
final localLast = state.last;
final serverText = serverLast.content.trim();
final localText = localLast.content.trim();
final sameLastId = serverLast.id == localLast.id;
final isAssistant = serverLast.role == 'assistant';
final serverHasMore =
serverText.isNotEmpty && serverText.length > localText.length;
final localEmptyButServerHas =
localText.isEmpty && serverText.isNotEmpty;
if (sameLastId &&
isAssistant &&
(serverHasMore || localEmptyButServerHas)) {
state = serverMessages;
return;
}
}
}
2025-09-21 22:31:44 +05:30
return;
2025-08-10 01:20:45 +05:30
}
2025-09-21 22:31:44 +05:30
// Cancel any existing message stream when switching conversations
_cancelMessageStream();
// Also cancel typing guard on conversation switch
_cancelTypingGuard();
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
if (next != null) {
state = next.messages;
2025-08-21 14:37:49 +05:30
2025-09-21 22:31:44 +05:30
// Update selected model if conversation has a different model
_updateModelForConversation(next);
} else {
state = [];
}
});
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
ref.onDispose(() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
_cancelMessageStream();
2025-09-26 01:38:00 +05:30
cancelSocketSubscriptions();
2025-09-21 22:31:44 +05:30
_cancelTypingGuard();
_conversationListener?.close();
_conversationListener = null;
});
}
final activeConversation = ref.read(activeConversationProvider);
return activeConversation?.messages ?? const [];
2025-08-10 01:20:45 +05:30
}
void _addSubscription(StreamSubscription subscription) {
_subscriptions.add(subscription);
}
void _cancelMessageStream() {
_messageStream?.cancel();
_messageStream = null;
2025-09-26 01:38:00 +05:30
cancelSocketSubscriptions();
2025-08-10 01:20:45 +05:30
}
2025-09-05 23:08:23 +05:30
void _cancelTypingGuard() {
2025-09-07 23:17:26 +05:30
_typingWatchdog?.stop();
_typingWatchdog = null;
2025-09-05 23:08:23 +05:30
}
void _scheduleTypingGuard({Duration? timeout}) {
// Default timeout tuned to balance long tool gaps and UX
final effectiveTimeout = timeout ?? const Duration(seconds: 25);
2025-09-07 23:17:26 +05:30
_typingWatchdog ??= InactivityWatchdog(
window: effectiveTimeout,
onTimeout: () async {
try {
if (state.isEmpty) return;
final last = state.last;
// Still the same streaming message and no finish signal
if (last.role == 'assistant' && last.isStreaming) {
// Attempt a soft recovery: if content is still empty, try fetching final content from server
if ((last.content).trim().isEmpty) {
try {
2025-09-21 22:31:44 +05:30
final apiSvc = ref.read(apiServiceProvider);
final activeConv = ref.read(activeConversationProvider);
2025-09-07 23:17:26 +05:30
final msgId = last.id;
final chatId = activeConv?.id;
if (apiSvc != null && chatId != null && chatId.isNotEmpty) {
2025-09-16 18:15:44 +05:30
final resp = await apiSvc.dio.get('/api/v1/chats/$chatId');
2025-09-07 23:17:26 +05:30
final data = resp.data as Map<String, dynamic>;
String content = '';
final chatObj = data['chat'] as Map<String, dynamic>?;
if (chatObj != null) {
final list = chatObj['messages'];
if (list is List) {
final target = list.firstWhere(
(m) => (m is Map && (m['id']?.toString() == msgId)),
orElse: () => null,
);
if (target != null) {
final rawContent = (target as Map)['content'];
2025-09-05 23:08:23 +05:30
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) {
2025-09-13 10:16:58 +05:30
content =
(textItem as Map)['text']?.toString() ?? '';
2025-09-07 23:17:26 +05:30
}
}
}
}
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 as Map)['text']?.toString() ?? '';
}
2025-09-05 23:08:23 +05:30
}
}
}
}
}
2025-09-07 23:17:26 +05:30
if (content.isNotEmpty) {
replaceLastMessageContent(content);
}
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
} catch (_) {}
}
// Regardless of fetch result, ensure UI is not stuck
finishStreaming();
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
} finally {
_cancelTypingGuard();
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
},
);
_typingWatchdog!.setWindow(effectiveTimeout);
_typingWatchdog!.ping();
2025-09-05 23:08:23 +05:30
}
void _touchStreamingActivity() {
// Keep guard alive while streaming
if (state.isNotEmpty) {
final last = state.last;
if (last.role == 'assistant' && last.isStreaming) {
// Compute a dynamic timeout based on flow type
Duration timeout = const Duration(seconds: 25);
try {
final meta = last.metadata ?? const <String, dynamic>{};
final isBgFlow = (meta['backgroundFlow'] == true);
final isWebSearchFlow =
2025-09-07 21:41:13 +05:30
(meta['webSearchFlow'] == true) ||
(meta['webSearchActive'] == true);
2025-09-05 23:08:23 +05:30
final isImageGenFlow = (meta['imageGenerationFlow'] == true);
// Also consult global toggles if metadata not present
2025-09-21 22:31:44 +05:30
final globalWebSearch = ref.read(webSearchEnabledProvider);
final webSearchAvailable = ref.read(webSearchAvailableProvider);
final globalImageGen = ref.read(imageGenerationEnabledProvider);
2025-09-05 23:08:23 +05:30
2025-09-07 23:17:26 +05:30
// Extend guard windows to tolerate long reasoning/tools (> 1 min)
2025-09-05 23:08:23 +05:30
if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) {
2025-09-07 23:17:26 +05:30
if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60);
2025-09-05 23:08:23 +05:30
}
if (isBgFlow) {
2025-09-07 23:17:26 +05:30
// Background tools/dynamic channel can be much longer
if (timeout.inSeconds < 120) timeout = const Duration(seconds: 120);
2025-09-05 23:08:23 +05:30
}
if (isImageGenFlow || globalImageGen) {
// Image generation tends to be the longest
2025-09-07 23:17:26 +05:30
if (timeout.inSeconds < 180) timeout = const Duration(seconds: 180);
2025-09-05 23:08:23 +05:30
}
} catch (_) {}
_scheduleTypingGuard(timeout: timeout);
}
}
}
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-09-21 22:31:44 +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 {
2025-09-21 22:31:44 +05:30
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
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(conversationModel);
2025-08-17 00:26:12 +05:30
} 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);
}
2025-09-26 01:38:00 +05:30
void setSocketSubscriptions(
List<SocketEventSubscription> subscriptions, {
VoidCallback? onDispose,
}) {
cancelSocketSubscriptions();
_socketSubscriptions.addAll(subscriptions);
_socketTeardown = onDispose;
}
void cancelSocketSubscriptions() {
if (_socketSubscriptions.isEmpty) {
_socketTeardown?.call();
_socketTeardown = null;
return;
}
for (final sub in _socketSubscriptions) {
try {
sub.dispose();
} catch (_) {}
}
_socketSubscriptions.clear();
_socketTeardown?.call();
_socketTeardown = null;
}
2025-08-10 01:20:45 +05:30
void addMessage(ChatMessage message) {
state = [...state, message];
2025-09-05 23:08:23 +05:30
if (message.role == 'assistant' && message.isStreaming) {
_touchStreamingActivity();
}
2025-08-10 01:20:45 +05:30
}
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-09-05 23:08:23 +05:30
_touchStreamingActivity();
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-09-05 23:08:23 +05:30
final updated = updater(lastMessage);
state = [...state.sublist(0, state.length - 1), updated];
if (updated.isStreaming) {
_touchStreamingActivity();
}
2025-08-19 13:09:40 +05:30
}
2025-08-10 01:20:45 +05:30
2025-09-25 18:25:39 +05:30
void updateMessageById(
String messageId,
ChatMessage Function(ChatMessage current) updater,
) {
final index = state.indexWhere((m) => m.id == messageId);
if (index == -1) return;
final original = state[index];
final updated = updater(original);
if (identical(updated, original)) {
return;
}
final next = [...state];
next[index] = updated;
state = next;
}
void appendStatusUpdate(String messageId, ChatStatusUpdate update) {
updateMessageById(messageId, (current) {
final history = [...current.statusHistory, update];
return current.copyWith(statusHistory: history);
});
}
void setFollowUps(String messageId, List<String> followUps) {
updateMessageById(messageId, (current) {
return current.copyWith(followUps: List<String>.from(followUps));
});
}
void upsertCodeExecution(String messageId, ChatCodeExecution execution) {
updateMessageById(messageId, (current) {
final existing = current.codeExecutions;
final idx = existing.indexWhere((e) => e.id == execution.id);
if (idx == -1) {
return current.copyWith(codeExecutions: [...existing, execution]);
}
final next = [...existing];
next[idx] = execution;
return current.copyWith(codeExecutions: next);
});
}
void appendSourceReference(String messageId, ChatSourceReference reference) {
updateMessageById(messageId, (current) {
final existing = current.sources;
final alreadyPresent = existing.any((source) {
if (reference.id != null && reference.id!.isNotEmpty) {
return source.id == reference.id;
}
if (reference.url != null && reference.url!.isNotEmpty) {
return source.url == reference.url;
}
return false;
});
if (alreadyPresent) {
return current;
}
return current.copyWith(sources: [...existing, reference]);
});
}
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),
];
2025-09-05 23:08:23 +05:30
_touchStreamingActivity();
2025-08-10 01:20:45 +05:30
}
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
];
2025-09-05 23:08:23 +05:30
_touchStreamingActivity();
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
];
2025-09-05 23:08:23 +05:30
_cancelTypingGuard();
2025-09-07 18:51:59 +05:30
// Trigger a refresh of the conversations list so UI like the Chats Drawer
// can pick up updated titles and ordering once streaming completes.
// Best-effort: ignore if ref lifecycle/context prevents invalidation.
try {
2025-09-21 22:31:44 +05:30
ref.invalidate(conversationsProvider);
2025-09-07 18:51:59 +05:30
} catch (_) {}
2025-08-10 01:20:45 +05:30
}
}
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,
2025-09-20 18:28:12 +05:30
String? systemPrompt,
2025-09-05 11:48:43 +05:30
}) async {
// Choose id: reuse existing if provided, else create new
final String assistantMessageId =
(existingAssistantId != null && existingAssistantId.isNotEmpty)
2025-09-07 21:41:13 +05:30
? existingAssistantId
: const Uuid().v4();
2025-09-05 11:48:43 +05:30
// 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;
2025-09-07 21:41:13 +05:30
if (last != null &&
last.id == assistantMessageId &&
last.role == 'assistant' &&
!last.isStreaming) {
ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(
2025-09-05 11:48:43 +05:30
(m) => m.copyWith(isStreaming: true),
);
}
} catch (_) {}
}
// Sync conversation state to ensure WebUI can load conversation history
2025-09-05 11:48:43 +05:30
try {
final api = ref.read(apiServiceProvider);
final activeConv = ref.read(activeConversationProvider);
2025-09-05 11:48:43 +05:30
if (api != null && activeConv != null) {
2025-09-21 22:31:44 +05:30
final resolvedSystemPrompt =
(systemPrompt != null && systemPrompt.trim().isNotEmpty)
2025-09-20 18:28:12 +05:30
? systemPrompt.trim()
: activeConv.systemPrompt;
2025-09-05 11:48:43 +05:30
final current = ref.read(chatMessagesProvider);
await api.syncConversationMessages(
2025-09-05 11:48:43 +05:30
activeConv.id,
current,
model: modelId,
2025-09-20 18:28:12 +05:30
systemPrompt: resolvedSystemPrompt,
2025-09-05 11:48:43 +05:30
);
}
} catch (_) {
// Non-critical - continue if sync fails
}
2025-09-05 11:48:43 +05:30
return assistantMessageId;
}
2025-09-20 18:47:38 +05:30
String? _extractSystemPromptFromSettings(Map<String, dynamic>? settings) {
if (settings == null) return null;
final rootValue = settings['system'];
if (rootValue is String) {
final trimmed = rootValue.trim();
if (trimmed.isNotEmpty) return trimmed;
}
final ui = settings['ui'];
if (ui is Map<String, dynamic>) {
final uiValue = ui['system'];
if (uiValue is String) {
final trimmed = uiValue.trim();
if (trimmed.isNotEmpty) return trimmed;
}
}
return null;
}
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
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).clear();
2025-08-10 01:20:45 +05:30
// Clear messages
ref.read(chatMessagesProvider.notifier).clearMessages();
}
// Available tools provider
2025-09-21 22:31:44 +05:30
final availableToolsProvider =
NotifierProvider<AvailableToolsNotifier, List<String>>(
AvailableToolsNotifier.new,
);
2025-08-10 01:20:45 +05:30
// Web search enabled state for API-based web search
2025-09-21 22:31:44 +05:30
final webSearchEnabledProvider =
NotifierProvider<WebSearchEnabledNotifier, bool>(
WebSearchEnabledNotifier.new,
);
2025-08-10 01:20:45 +05:30
2025-08-21 14:37:49 +05:30
// Image generation enabled state - behaves like web search
2025-09-21 22:31:44 +05:30
final imageGenerationEnabledProvider =
NotifierProvider<ImageGenerationEnabledNotifier, bool>(
ImageGenerationEnabledNotifier.new,
);
2025-08-21 14:37:49 +05:30
2025-08-10 01:20:45 +05:30
// Vision capable models provider
2025-09-21 22:31:44 +05:30
final visionCapableModelsProvider =
NotifierProvider<VisionCapableModelsNotifier, List<String>>(
VisionCapableModelsNotifier.new,
);
// File upload capable models provider
final fileUploadCapableModelsProvider =
NotifierProvider<FileUploadCapableModelsNotifier, List<String>>(
FileUploadCapableModelsNotifier.new,
);
class AvailableToolsNotifier extends Notifier<List<String>> {
@override
List<String> build() => [];
void set(List<String> tools) => state = List<String>.from(tools);
}
class WebSearchEnabledNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
class ImageGenerationEnabledNotifier extends Notifier<bool> {
@override
bool build() => false;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
void set(bool value) => state = value;
}
class VisionCapableModelsNotifier extends Notifier<List<String>> {
@override
List<String> build() {
final selectedModel = ref.watch(selectedModelProvider);
if (selectedModel == null) {
return [];
}
if (selectedModel.isMultimodal == true) {
return [selectedModel.id];
}
// For now, assume all models support vision unless explicitly marked
2025-08-10 01:20:45 +05:30
return [selectedModel.id];
}
2025-09-21 22:31:44 +05:30
}
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
class FileUploadCapableModelsNotifier extends Notifier<List<String>> {
@override
List<String> build() {
final selectedModel = ref.watch(selectedModelProvider);
if (selectedModel == null) {
return [];
}
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
// For now, assume all models support file upload
return [selectedModel.id];
}
}
2025-08-10 01:20:45 +05:30
// Helper function to validate file size
bool validateFileSize(int fileSize, int? maxSizeMB) {
if (maxSizeMB == null) return true;
final maxSizeBytes = maxSizeMB * 1024 * 1024;
return fileSize <= maxSizeBytes;
}
// Helper function to validate file count
bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) {
if (maxCount == null) return true;
return (currentCount + newFilesCount) <= maxCount;
}
// Helper function to build files array from attachment IDs
Future<List<Map<String, dynamic>>?> _buildFilesArrayFromAttachments(
dynamic api,
List<String> attachmentIds,
) async {
final filesArray = <Map<String, dynamic>>[];
for (final attachmentId in attachmentIds) {
try {
final fileInfo = await api.getFileInfo(attachmentId);
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
final fileSize = fileInfo['size'];
// Check if it's an image
final ext = fileName.toLowerCase().split('.').last;
final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext);
// Add all files to the files array for WebUI display
// Note: This is for storage/display, not for API message sending
filesArray.add({
'type': isImage ? 'image' : 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': fileName,
if (fileSize != null) 'size': fileSize,
});
} catch (_) {
// If we can't get file info, assume it's a non-image file
// Images should be handled in the content array anyway
filesArray.add({
'type': 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': 'Unknown',
});
}
}
return filesArray.isNotEmpty ? filesArray : null;
}
2025-08-10 01:20:45 +05:30
// 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;
}
}
2025-09-07 21:41:13 +05:30
// Small internal helper to convert a message with attachments into the
// OpenWebUI content payload format (text + image_url + files).
// - Adds text first (if non-empty)
// - Converts image attachments to image_url with data URLs (resolving MIME type when needed)
// - Includes non-image attachments in a 'files' array for server-side resolution
Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
required dynamic api,
required String role,
required String cleanedText,
required List<String> attachmentIds,
}) async {
final List<Map<String, dynamic>> contentArray = [];
if (cleanedText.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleanedText});
}
// Collect all files in OpenWebUI format for the files array
final allFiles = <Map<String, dynamic>>[];
2025-09-07 21:41:13 +05:30
for (final attachmentId in attachmentIds) {
try {
final fileInfo = await api.getFileInfo(attachmentId);
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
final fileSize = fileInfo['size'];
2025-09-07 21:41:13 +05:30
final base64Data = await _getFileAsBase64(api, attachmentId);
if (base64Data != null) {
// This is an image file - add to content array only
2025-09-07 21:41:13 +05:30
if (base64Data.startsWith('data:')) {
contentArray.add({
'type': 'image_url',
'image_url': {'url': base64Data},
});
} else {
final ext = fileName.toLowerCase().split('.').last;
String mimeType = 'image/png';
if (ext == 'jpg' || ext == 'jpeg') {
mimeType = 'image/jpeg';
} else if (ext == 'gif') {
mimeType = 'image/gif';
} else if (ext == 'webp') {
mimeType = 'image/webp';
2025-09-07 21:41:13 +05:30
}
final dataUrl = 'data:$mimeType;base64,$base64Data';
contentArray.add({
'type': 'image_url',
'image_url': {'url': dataUrl},
});
2025-09-07 21:41:13 +05:30
}
// Note: Images are handled in content array above, no need to duplicate in files array
// This prevents duplicate display in the WebUI
2025-09-07 21:41:13 +05:30
} else {
// This is a non-image file
allFiles.add({
'type': 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': fileName,
if (fileSize != null) 'size': fileSize,
});
2025-09-07 21:41:13 +05:30
}
} catch (_) {
// Swallow and continue to keep regeneration robust
}
}
final messageMap = <String, dynamic>{
'role': role,
'content': contentArray.isNotEmpty ? contentArray : cleanedText,
};
if (allFiles.isNotEmpty) {
messageMap['files'] = allFiles;
2025-09-07 21:41:13 +05:30
}
return messageMap;
}
// Regenerate message function that doesn't duplicate user message
Future<void> regenerateMessage(
2025-09-07 21:41:13 +05:30
dynamic 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');
}
2025-09-20 18:28:12 +05:30
var activeConversation = ref.read(activeConversationProvider);
if (activeConversation == null) {
throw Exception('No active conversation');
}
// In reviewer mode, simulate response
if (reviewerMode) {
final assistantMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content: '',
timestamp: DateTime.now(),
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 {
2025-09-20 18:28:12 +05:30
Map<String, dynamic>? userSettingsData;
String? userSystemPrompt;
try {
userSettingsData = await api!.getUserSettings();
2025-09-20 18:47:38 +05:30
userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData);
2025-09-20 18:28:12 +05:30
} catch (_) {}
if ((activeConversation.systemPrompt == null ||
activeConversation.systemPrompt!.trim().isEmpty) &&
(userSystemPrompt?.isNotEmpty ?? false)) {
2025-09-21 22:31:44 +05:30
final updated = activeConversation.copyWith(
systemPrompt: userSystemPrompt,
);
ref.read(activeConversationProvider.notifier).set(updated);
2025-09-20 18:28:12 +05:30
activeConversation = updated;
}
2025-09-07 21:41:13 +05:30
// Include selected tool ids so provider-native tool calling is triggered
final selectedToolIds = ref.read(selectedToolIdsProvider);
// Get conversation history for context (excluding the removed assistant message)
final List<ChatMessage> messages = ref.read(chatMessagesProvider);
2025-08-21 14:37:49 +05:30
final List<Map<String, dynamic>> conversationMessages =
<Map<String, dynamic>>[];
2025-09-07 21:41:13 +05:30
for (int i = 0; i < messages.length; i++) {
final msg = messages[i];
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
2025-09-05 11:15:39 +05:30
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
2025-09-07 21:41:13 +05:30
// Prefer provided attachments for the last user message; otherwise use message attachments
2025-09-13 10:16:58 +05:30
final bool isLastUser =
(i == messages.length - 1) && msg.role == 'user';
2025-09-07 21:41:13 +05:30
final List<String> messageAttachments =
(isLastUser && (attachments != null && attachments.isNotEmpty))
2025-09-13 10:16:58 +05:30
? List<String>.from(attachments)
: (msg.attachmentIds ?? const <String>[]);
2025-09-07 21:41:13 +05:30
if (messageAttachments.isNotEmpty) {
final messageMap = await _buildMessagePayloadWithAttachments(
api: api,
role: msg.role,
cleanedText: cleaned,
attachmentIds: messageAttachments,
);
conversationMessages.add(messageMap);
} else {
2025-09-05 11:15:39 +05:30
conversationMessages.add({'role': msg.role, 'content': cleaned});
}
}
}
2025-09-20 18:28:12 +05:30
final conversationSystemPrompt = activeConversation.systemPrompt?.trim();
2025-09-21 22:31:44 +05:30
final effectiveSystemPrompt =
(conversationSystemPrompt != null &&
2025-09-20 18:28:12 +05:30
conversationSystemPrompt.isNotEmpty)
? conversationSystemPrompt
: userSystemPrompt;
if (effectiveSystemPrompt != null && effectiveSystemPrompt.isNotEmpty) {
final hasSystemMessage = conversationMessages.any(
(m) => (m['role']?.toString().toLowerCase() ?? '') == 'system',
);
if (!hasSystemMessage) {
2025-09-21 22:31:44 +05:30
conversationMessages.insert(0, {
'role': 'system',
'content': effectiveSystemPrompt,
});
2025-09-20 18:28:12 +05:30
}
}
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-20 18:28:12 +05:30
systemPrompt: effectiveSystemPrompt,
);
2025-09-05 11:15:39 +05:30
2025-09-07 21:41:13 +05:30
// Feature toggles
final webSearchEnabled =
ref.read(webSearchEnabledProvider) &&
ref.read(webSearchAvailableProvider);
final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider);
// Model metadata for completion notifications
final supportedParams =
selectedModel.supportedParameters ??
[
'max_tokens',
'tool_choice',
'tools',
'response_format',
'structured_outputs',
];
final modelItem = {
'id': selectedModel.id,
'canonical_slug': selectedModel.id,
'hugging_face_id': '',
'name': selectedModel.name,
'created': 1754089419,
'description':
selectedModel.description ??
'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrouter/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.',
'context_length': 256000,
'architecture': {
'modality': 'text+image->text',
'input_modalities': ['image', 'text'],
'output_modalities': ['text'],
'tokenizer': 'Other',
'instruct_type': null,
},
'pricing': {
'prompt': '0',
'completion': '0',
'request': '0',
'image': '0',
'audio': '0',
'web_search': '0',
'internal_reasoning': '0',
},
'top_provider': {
'context_length': 256000,
'max_completion_tokens': 128000,
'is_moderated': false,
},
'per_request_limits': null,
'supported_parameters': supportedParams,
'connection_type': 'external',
'owned_by': 'openai',
'openai': {
'id': selectedModel.id,
'canonical_slug': selectedModel.id,
'hugging_face_id': '',
'name': selectedModel.name,
'created': 1754089419,
'description':
selectedModel.description ??
'This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrout'
'er/horizon-alpha)\n\nNote: It\'s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training.',
'context_length': 256000,
'architecture': {
'modality': 'text+image->text',
'input_modalities': ['image', 'text'],
'output_modalities': ['text'],
'tokenizer': 'Other',
'instruct_type': null,
},
'pricing': {
'prompt': '0',
'completion': '0',
'request': '0',
'image': '0',
'audio': '0',
'web_search': '0',
'internal_reasoning': '0',
},
'top_provider': {
'context_length': 256000,
'max_completion_tokens': 128000,
'is_moderated': false,
},
'per_request_limits': null,
'supported_parameters': [
'max_tokens',
'tool_choice',
'tools',
'response_format',
'structured_outputs',
],
'connection_type': 'external',
},
'urlIdx': 0,
'actions': <dynamic>[],
'filters': <dynamic>[],
'tags': <dynamic>[],
};
// Socket binding for background flows
final socketService = ref.read(socketServiceProvider);
2025-09-07 22:37:52 +05:30
String? socketSessionId = socketService?.sessionId;
bool wantSessionBinding =
2025-09-07 21:41:13 +05:30
(socketService?.isConnected == true) &&
(socketSessionId != null && socketSessionId.isNotEmpty);
2025-09-07 22:37:52 +05:30
// When regenerating with tools, make a best-effort to ensure a live socket.
if (!wantSessionBinding && socketService != null) {
try {
final ok = await socketService.ensureConnected();
if (ok) {
socketSessionId = socketService.sessionId;
wantSessionBinding =
socketSessionId != null && socketSessionId.isNotEmpty;
}
} catch (_) {}
}
2025-09-07 21:41:13 +05:30
// Resolve tool servers from user settings (if any)
List<Map<String, dynamic>>? toolServers;
2025-09-20 18:28:12 +05:30
final uiSettings = userSettingsData?['ui'] as Map<String, dynamic>?;
2025-09-21 22:31:44 +05:30
final rawServers = uiSettings != null
? (uiSettings['toolServers'] as List?)
: null;
2025-09-20 18:28:12 +05:30
if (rawServers != null && rawServers.isNotEmpty) {
try {
2025-09-07 21:41:13 +05:30
toolServers = await _resolveToolServers(rawServers, api);
2025-09-20 18:28:12 +05:30
} catch (_) {}
}
2025-09-07 21:41:13 +05:30
// Background tasks parity with Web client (safe defaults)
bool shouldGenerateTitle = false;
try {
final conv = ref.read(activeConversationProvider);
final nonSystemCount = conversationMessages
.where((m) => (m['role']?.toString() ?? '') != 'system')
.length;
shouldGenerateTitle =
(conv == null) ||
((conv.title == 'New Chat' || (conv.title.isEmpty)) &&
nonSystemCount == 1);
} catch (_) {}
final bgTasks = <String, dynamic>{
if (shouldGenerateTitle) 'title_generation': true,
if (shouldGenerateTitle) 'tags_generation': true,
'follow_up_generation': true,
if (webSearchEnabled) 'web_search': true,
if (imageGenerationEnabled) 'image_generation': true,
};
final bool isBackgroundToolsFlowPre =
(selectedToolIds.isNotEmpty) ||
(toolServers != null && toolServers.isNotEmpty);
final bool isBackgroundWebSearchPre = webSearchEnabled;
// Dispatch using unified send pipeline (background tools flow)
2025-09-16 18:15:44 +05:30
final bool isBackgroundFlowPre =
2025-09-13 10:16:58 +05:30
isBackgroundToolsFlowPre ||
isBackgroundWebSearchPre ||
imageGenerationEnabled;
2025-09-26 13:59:28 +05:30
final bool passSocketSession =
wantSessionBinding && (isBackgroundFlowPre || bgTasks.isNotEmpty);
2025-09-05 11:15:39 +05:30
final response = api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
2025-09-07 21:41:13 +05:30
toolIds: selectedToolIds.isNotEmpty ? selectedToolIds : null,
enableWebSearch: webSearchEnabled,
enableImageGeneration: imageGenerationEnabled,
modelItem: modelItem,
2025-09-16 18:15:44 +05:30
sessionIdOverride: passSocketSession ? socketSessionId : null,
2025-09-26 01:38:00 +05:30
socketSessionId: socketSessionId,
2025-09-07 21:41:13 +05:30
toolServers: toolServers,
backgroundTasks: bgTasks,
2025-09-05 11:15:39 +05:30
responseMessageId: assistantMessageId,
);
final stream = response.stream;
2025-09-07 21:41:13 +05:30
final sessionId = response.sessionId;
2025-09-26 01:38:00 +05:30
final effectiveSessionId =
response.socketSessionId ?? socketSessionId ?? sessionId;
2025-09-26 13:59:28 +05:30
final bool isBackgroundFlow = response.isBackgroundFlow;
2025-09-07 21:41:13 +05:30
try {
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
final mergedMeta = {
if (m.metadata != null) ...m.metadata!,
2025-09-16 18:15:44 +05:30
'backgroundFlow': isBackgroundFlow,
2025-09-07 21:41:13 +05:30
if (isBackgroundWebSearchPre) 'webSearchFlow': true,
if (imageGenerationEnabled) 'imageGenerationFlow': true,
};
return m.copyWith(metadata: mergedMeta);
});
} catch (_) {}
2025-09-26 01:38:00 +05:30
final activeStream = attachUnifiedChunkedStreaming(
2025-09-07 21:41:13 +05:30
stream: stream,
webSearchEnabled: webSearchEnabled,
assistantMessageId: assistantMessageId,
modelId: selectedModel.id,
modelItem: modelItem,
2025-09-26 01:38:00 +05:30
sessionId: effectiveSessionId,
2025-09-07 21:41:13 +05:30
activeConversationId: activeConversation.id,
api: api,
socketService: socketService,
appendToLastMessage: (c) =>
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),
replaceLastMessageContent: (c) =>
ref.read(chatMessagesProvider.notifier).replaceLastMessageContent(c),
updateLastMessageWith: (updater) => ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(updater),
2025-09-25 18:25:39 +05:30
appendStatusUpdate: (messageId, update) => ref
.read(chatMessagesProvider.notifier)
.appendStatusUpdate(messageId, update),
setFollowUps: (messageId, followUps) => ref
.read(chatMessagesProvider.notifier)
.setFollowUps(messageId, followUps),
upsertCodeExecution: (messageId, execution) => ref
.read(chatMessagesProvider.notifier)
.upsertCodeExecution(messageId, execution),
appendSourceReference: (messageId, reference) => ref
.read(chatMessagesProvider.notifier)
.appendSourceReference(messageId, reference),
updateMessageById: (messageId, updater) => ref
.read(chatMessagesProvider.notifier)
.updateMessageById(messageId, updater),
onChatTitleUpdated: (newTitle) {
final active = ref.read(activeConversationProvider);
if (active != null) {
ref
.read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle));
}
ref.invalidate(conversationsProvider);
},
onChatTagsUpdated: () {
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
final api = ref.read(apiServiceProvider);
if (active != null && api != null) {
Future.microtask(() async {
try {
final refreshed = await api.getConversation(active.id);
ref.read(activeConversationProvider.notifier).set(refreshed);
} catch (_) {}
});
}
},
2025-09-07 21:41:13 +05:30
finishStreaming: () =>
ref.read(chatMessagesProvider.notifier).finishStreaming(),
getMessages: () => ref.read(chatMessagesProvider),
);
2025-09-26 01:38:00 +05:30
ref.read(chatMessagesProvider.notifier)
..setMessageStream(activeStream.streamSubscription)
..setSocketSubscriptions(
activeStream.socketSubscriptions,
onDispose: activeStream.disposeWatchdog,
);
2025-09-07 21:41:13 +05:30
return;
} 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');
}
2025-09-20 18:28:12 +05:30
Map<String, dynamic>? userSettingsData;
String? userSystemPrompt;
if (!reviewerMode && api != null) {
try {
userSettingsData = await api.getUserSettings();
2025-09-20 18:47:38 +05:30
userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData);
2025-09-20 18:28:12 +05:30
} catch (_) {}
}
2025-08-10 01:20:45 +05:30
// 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
List<Map<String, dynamic>>? userFiles;
if (attachments != null &&
attachments.isNotEmpty &&
!reviewerMode &&
api != null) {
userFiles = await _buildFilesArrayFromAttachments(api, attachments);
}
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,
files: userFiles,
2025-08-12 13:07:10 +05:30
);
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-09-20 18:28:12 +05:30
systemPrompt: userSystemPrompt,
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
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(localConversation);
2025-08-10 01:20:45 +05:30
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,
2025-09-20 18:28:12 +05:30
systemPrompt: userSystemPrompt,
2025-08-10 01:20:45 +05:30
);
final updatedConversation = localConversation.copyWith(
id: serverConversation.id,
2025-09-20 18:28:12 +05:30
systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt,
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
);
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(updatedConversation);
2025-08-10 01:20:45 +05:30
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
}
2025-09-20 18:28:12 +05:30
if (activeConversation != null &&
(activeConversation.systemPrompt == null ||
activeConversation.systemPrompt!.trim().isEmpty) &&
(userSystemPrompt?.isNotEmpty ?? false)) {
final updated = activeConversation.copyWith(systemPrompt: userSystemPrompt);
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(updated);
2025-09-20 18:28:12 +05:30
activeConversation = updated;
}
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: '',
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-09-07 21:41:13 +05:30
final List<String> ids = msg.attachmentIds ?? const <String>[];
if (ids.isNotEmpty) {
final messageMap = await _buildMessagePayloadWithAttachments(
api: api,
role: msg.role,
cleanedText: cleaned,
attachmentIds: ids,
);
2025-08-10 01:20:45 +05:30
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
}
}
}
2025-09-20 18:28:12 +05:30
final conversationSystemPrompt = activeConversation?.systemPrompt?.trim();
2025-09-21 22:31:44 +05:30
final effectiveSystemPrompt =
(conversationSystemPrompt != null && conversationSystemPrompt.isNotEmpty)
2025-09-20 18:28:12 +05:30
? conversationSystemPrompt
: userSystemPrompt;
if (effectiveSystemPrompt != null && effectiveSystemPrompt.isNotEmpty) {
final hasSystemMessage = conversationMessages.any(
(m) => (m['role']?.toString().toLowerCase() ?? '') == 'system',
);
if (!hasSystemMessage) {
2025-09-21 22:31:44 +05:30
conversationMessages.insert(0, {
'role': 'system',
'content': effectiveSystemPrompt,
});
2025-09-20 18:28:12 +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);
// Sync conversation state to ensure WebUI can load conversation history
2025-09-05 11:15:39 +05:30
try {
final activeConvForSeed = ref.read(activeConversationProvider);
if (activeConvForSeed != null) {
final msgsForSeed = ref.read(chatMessagesProvider);
await api.syncConversationMessages(
2025-09-05 11:15:39 +05:30
activeConvForSeed.id,
msgsForSeed,
model: selectedModel.id,
2025-09-20 18:28:12 +05:30
systemPrompt: effectiveSystemPrompt,
2025-09-05 11:15:39 +05:30
);
}
} catch (_) {
// Non-critical - continue if sync fails
}
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-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;
2025-09-20 18:28:12 +05:30
final uiSettings = userSettingsData?['ui'] as Map<String, dynamic>?;
2025-09-21 22:31:44 +05:30
final rawServers = uiSettings != null
? (uiSettings['toolServers'] as List?)
: null;
2025-09-20 18:28:12 +05:30
if (rawServers != null && rawServers.isNotEmpty) {
try {
2025-08-31 14:02:44 +05:30
toolServers = await _resolveToolServers(rawServers, api);
2025-09-20 18:28:12 +05:30
} catch (_) {}
}
2025-08-31 14:02:44 +05:30
// 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-07 21:41:13 +05:30
shouldGenerateTitle =
(conv == null) ||
((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-07 21:41:13 +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-09-26 13:59:28 +05:30
final bool shouldBindSession =
wantSessionBinding &&
(isBackgroundToolsFlowPre ||
isBackgroundWebSearchPre ||
imageGenerationEnabled ||
bgTasks.isNotEmpty);
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).
2025-09-26 13:59:28 +05:30
sessionIdOverride: shouldBindSession ? socketSessionId : null,
2025-09-26 01:38:00 +05:30
socketSessionId: socketSessionId,
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-09-26 01:38:00 +05:30
final effectiveSessionId =
response.socketSessionId ?? socketSessionId ?? sessionId;
2025-08-10 01:20:45 +05:30
2025-09-25 12:28:02 +05:30
// Use unified streaming helper for SSE/WebSocket handling
2025-09-26 13:59:28 +05:30
final bool isBackgroundFlow = response.isBackgroundFlow;
2025-09-25 12:28:02 +05:30
2025-09-05 23:08:23 +05:30
try {
2025-09-07 21:41:13 +05:30
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
2025-09-05 23:08:23 +05:30
final mergedMeta = {
if (m.metadata != null) ...m.metadata!,
'backgroundFlow': isBackgroundFlow,
if (isBackgroundWebSearchPre) 'webSearchFlow': true,
if (imageGenerationEnabled) 'imageGenerationFlow': true,
};
return m.copyWith(metadata: mergedMeta);
});
} catch (_) {}
2025-09-26 01:38:00 +05:30
final activeStream = attachUnifiedChunkedStreaming(
2025-09-25 12:28:02 +05:30
stream: stream,
webSearchEnabled: webSearchEnabled,
assistantMessageId: assistantMessageId,
modelId: selectedModel.id,
modelItem: modelItem,
2025-09-26 01:38:00 +05:30
sessionId: effectiveSessionId,
2025-09-25 12:28:02 +05:30
activeConversationId: activeConversation?.id,
api: api,
socketService: socketService,
appendToLastMessage: (c) =>
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),
replaceLastMessageContent: (c) =>
ref.read(chatMessagesProvider.notifier).replaceLastMessageContent(c),
updateLastMessageWith: (updater) => ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(updater),
2025-09-25 18:25:39 +05:30
appendStatusUpdate: (messageId, update) => ref
.read(chatMessagesProvider.notifier)
.appendStatusUpdate(messageId, update),
setFollowUps: (messageId, followUps) => ref
.read(chatMessagesProvider.notifier)
.setFollowUps(messageId, followUps),
upsertCodeExecution: (messageId, execution) => ref
.read(chatMessagesProvider.notifier)
.upsertCodeExecution(messageId, execution),
appendSourceReference: (messageId, reference) => ref
.read(chatMessagesProvider.notifier)
.appendSourceReference(messageId, reference),
updateMessageById: (messageId, updater) => ref
.read(chatMessagesProvider.notifier)
.updateMessageById(messageId, updater),
onChatTitleUpdated: (newTitle) {
final active = ref.read(activeConversationProvider);
if (active != null) {
ref
.read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle));
}
ref.invalidate(conversationsProvider);
},
onChatTagsUpdated: () {
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
final api = ref.read(apiServiceProvider);
if (active != null && api != null) {
Future.microtask(() async {
try {
final refreshed = await api.getConversation(active.id);
ref.read(activeConversationProvider.notifier).set(refreshed);
} catch (_) {}
});
}
},
2025-09-25 12:28:02 +05:30
finishStreaming: () =>
ref.read(chatMessagesProvider.notifier).finishStreaming(),
getMessages: () => ref.read(chatMessagesProvider),
2025-08-10 01:20:45 +05:30
);
2025-09-26 01:38:00 +05:30
ref.read(chatMessagesProvider.notifier)
..setMessageStream(activeStream.streamSubscription)
..setSocketSubscriptions(
activeStream.socketSubscriptions,
onDispose: activeStream.disposeWatchdog,
);
2025-09-25 12:28:02 +05:30
return;
2025-08-10 01:20:45 +05:30
} 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);
2025-09-25 12:28:02 +05:30
} else if (e.toString().contains('401') || e.toString().contains('403')) {
// Authentication errors - clear auth state and redirect to login
ref.invalidate(authStateManagerProvider);
2025-08-10 01:20:45 +05:30
} 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')) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'Model or endpoint not found (404)',
scope: 'chat/providers',
);
2025-08-10 01:20:45 +05:30
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'🤖 The selected AI model doesn\'t seem to be available.\n\n'
'Please try selecting a different model or check with your administrator.',
timestamp: DateTime.now(),
isStreaming: false,
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else {
// For other errors, provide a generic message and rethrow
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'❌ An unexpected error occurred while processing your request.\n\n'
'Please try again or check your connection.',
timestamp: DateTime.now(),
isStreaming: false,
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
}
}
}
// Save current conversation to OpenWebUI server
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));
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(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) {
2025-09-21 22:31:44 +05:30
ref
.read(activeConversationProvider.notifier)
.set(activeConversation!.copyWith(pinned: pinned));
2025-08-10 01:20:45 +05:30
}
} catch (e) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
'Error ${pinned ? 'pinning' : 'unpinning'} conversation: $e',
scope: 'chat/providers',
);
2025-08-10 01:20:45 +05:30
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) {
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).clear();
2025-08-10 01:20:45 +05:30
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) {
2025-09-25 23:22:48 +05:30
DebugLogger.log(
2025-08-10 01:20:45 +05:30
'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e',
2025-09-25 23:22:48 +05:30
scope: 'chat/providers',
2025-08-10 01:20:45 +05:30
);
// If server operation failed and we archived locally, restore the conversation
if (activeConversation?.id == conversationId && archived) {
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(activeConversation);
2025-08-10 01:20:45 +05:30
// 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) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Error sharing conversation: $e', scope: 'chat/providers');
2025-08-10 01:20:45 +05:30
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
2025-09-21 22:31:44 +05:30
ref.read(activeConversationProvider.notifier).set(clonedConversation);
2025-08-10 01:20:45 +05:30
// 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) {
2025-09-25 23:22:48 +05:30
DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers');
2025-08-10 01:20:45 +05:30
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-07 21:41:13 +05:30
// Force image generation enabled during regeneration
2025-09-21 22:31:44 +05:30
ref.read(imageGenerationEnabledProvider.notifier).set(true);
2025-09-07 21:41:13 +05:30
await regenerateMessage(
ref,
lastUserMessage.content,
lastUserMessage.attachmentIds,
);
2025-09-05 02:54:59 +05:30
} finally {
// restore previous state
2025-09-21 22:31:44 +05:30
ref.read(imageGenerationEnabledProvider.notifier).set(prev);
2025-08-21 15:45:07 +05:30
}
return;
}
2025-09-07 21:41:13 +05:30
// Text regeneration without duplicating user message
await regenerateMessage(
ref,
lastUserMessage.content,
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);
// 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) {
2025-09-07 21:41:13 +05:30
try {
await api.stopTask(t);
} catch (_) {}
2025-09-01 20:26:29 +05:30
}
} 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
2025-09-07 21:41:13 +05:30
// ========== Shared Streaming Utilities ==========
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;
}
2025-09-07 21:41:13 +05:30
Map<String, dynamic>? _resolveRef(
String ref,
Map<String, dynamic>? components,
) {
2025-08-31 14:02:44 +05:30
// 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];
2025-09-16 18:15:44 +05:30
if (schema is Map<String, dynamic>) {
2025-09-07 21:41:13 +05:30
return Map<String, dynamic>.from(schema);
2025-09-16 18:15:44 +05:30
}
2025-08-31 14:02:44 +05:30
}
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;
2025-09-16 18:15:44 +05:30
if (schema['description'] != null) {
2025-09-07 21:41:13 +05:30
out['description'] = schema['description'];
2025-09-16 18:15:44 +05:30
}
2025-08-31 14:02:44 +05:30
if (type == 'object') {
out['properties'] = <String, dynamic>{};
2025-09-16 18:15:44 +05:30
if (schema['required'] is List) {
2025-09-07 21:41:13 +05:30
out['required'] = List.from(schema['required']);
2025-09-16 18:15:44 +05:30
}
2025-08-31 14:02:44 +05:30
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>{};
}
2025-09-07 21:41:13 +05:30
List<Map<String, dynamic>> _convertOpenApiToToolPayload(
Map<String, dynamic> openApi,
) {
2025-08-31 14:02:44 +05:30
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'],
2025-09-07 21:41:13 +05:30
'description':
operation['description'] ??
operation['summary'] ??
'No description available.',
2025-08-31 14:02:44 +05:30
'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) {
2025-09-07 21:41:13 +05:30
String desc = (schema['description'] ?? p['description'] ?? '')
.toString();
2025-08-31 14:02:44 +05:30
if (schema['enum'] is List) {
2025-09-07 21:41:13 +05:30
desc =
'$desc. Possible values: ${(schema['enum'] as List).join(', ')}';
2025-08-31 14:02:44 +05:30
}
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'];
2025-09-07 21:41:13 +05:30
final resolved = _resolveSchemaSimple(
schema,
openApi['components'] as Map<String, dynamic>?,
);
2025-08-31 14:02:44 +05:30
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;
}