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