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

2803 lines
92 KiB
Dart
Raw Normal View History

import 'dart:async';
2025-08-12 13:07:10 +05:30
import 'dart:convert';
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:riverpod_annotation/riverpod_annotation.dart';
2025-08-10 01:20:45 +05:30
import 'package:uuid/uuid.dart';
import 'package:yaml/yaml.dart' as yaml;
import '../../../core/auth/auth_state_manager.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/services/conversation_delta_listener.dart';
import '../../../core/services/streaming_helper.dart';
import '../../../core/services/streaming_response_controller.dart';
import '../../../core/services/worker_manager.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/markdown_stream_formatter.dart';
import '../../../core/utils/tool_calls_parser.dart';
import '../models/chat_context_attachment.dart';
import '../providers/context_attachments_provider.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 '../services/reviewer_mode_service.dart';
part 'chat_providers.g.dart';
2025-09-25 22:36:42 +05:30
2025-09-01 16:47:41 +05:30
const bool kSocketVerboseLogging = false;
2025-08-10 01:20:45 +05:30
// Chat messages for current conversation
final chatMessagesProvider =
2025-09-21 22:31:44 +05:30
NotifierProvider<ChatMessagesNotifier, List<ChatMessage>>(
ChatMessagesNotifier.new,
);
2025-08-10 01:20:45 +05:30
/// Whether chat is currently streaming a response.
/// Used by router to avoid showing connection issues during active streaming.
final isChatStreamingProvider = Provider<bool>((ref) {
final messages = ref.watch(chatMessagesProvider);
if (messages.isEmpty) return false;
final last = messages.last;
return last.role == 'assistant' && last.isStreaming;
});
2025-08-10 01:20:45 +05:30
// Loading state for conversation (used to show chat skeletons during fetch)
@Riverpod(keepAlive: true)
class IsLoadingConversation extends _$IsLoadingConversation {
2025-09-21 22:31:44 +05:30
@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 {
2025-09-21 22:31:44 +05:30
@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 {
2025-09-21 22:31:44 +05:30
@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 {
2025-09-21 22:31:44 +05:30
@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;
}
// Whether the chat composer is allowed to auto-focus.
// When false, the composer will remain unfocused until the user taps it.
@Riverpod(keepAlive: true)
class ComposerAutofocusEnabled extends _$ComposerAutofocusEnabled {
@override
bool build() => true;
void set(bool value) => state = value;
}
// Chat messages notifier class
class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
StreamingResponseController? _messageStream;
2025-08-10 01:20:45 +05:30
ProviderSubscription? _conversationListener;
final List<StreamSubscription> _subscriptions = [];
2025-09-29 00:22:12 +05:30
final List<VoidCallback> _socketSubscriptions = [];
2025-09-26 01:38:00 +05:30
VoidCallback? _socketTeardown;
2025-09-27 16:34:37 +05:30
DateTime? _lastStreamingActivity;
Timer? _taskStatusTimer;
bool _taskStatusCheckInFlight = false;
bool _observedRemoteTask = false;
2025-08-10 01:20:45 +05:30
MarkdownStreamFormatter? _markdownFormatter;
String? _activeStreamingMessageId;
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();
_clearStreamingFormatter(); // Explicitly clear formatter on conversation switch
_stopRemoteTaskMonitor();
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);
if (_hasStreamingAssistant) {
_ensureRemoteTaskMonitor();
}
2025-09-21 22:31:44 +05:30
} else {
state = [];
_stopRemoteTaskMonitor();
2025-09-21 22:31:44 +05:30
}
});
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();
_stopRemoteTaskMonitor();
2025-09-21 22:31:44 +05:30
_conversationListener?.close();
_conversationListener = null;
});
}
final activeConversation = ref.read(activeConversationProvider);
return activeConversation?.messages ?? const [];
2025-08-10 01:20:45 +05:30
}
void _cancelMessageStream() {
final controller = _messageStream;
2025-08-10 01:20:45 +05:30
_messageStream = null;
if (controller != null && controller.isActive) {
unawaited(controller.cancel());
}
_clearStreamingFormatter();
2025-09-26 01:38:00 +05:30
cancelSocketSubscriptions();
_stopRemoteTaskMonitor();
2025-09-05 23:08:23 +05:30
}
void _clearStreamingFormatter() {
_markdownFormatter = null;
_activeStreamingMessageId = null;
}
bool get _hasStreamingAssistant {
if (state.isEmpty) return false;
final last = state.last;
return last.role == 'assistant' && last.isStreaming;
}
void _ensureRemoteTaskMonitor() {
if (_taskStatusTimer != null) {
return;
}
_taskStatusTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (!_taskStatusCheckInFlight) {
unawaited(_syncRemoteTaskStatus());
}
});
if (!_taskStatusCheckInFlight) {
unawaited(_syncRemoteTaskStatus());
}
}
void _stopRemoteTaskMonitor() {
_taskStatusTimer?.cancel();
_taskStatusTimer = null;
_taskStatusCheckInFlight = false;
_observedRemoteTask = false;
}
Future<void> _syncRemoteTaskStatus() async {
if (_taskStatusCheckInFlight) {
return;
}
if (!_hasStreamingAssistant) {
_stopRemoteTaskMonitor();
return;
}
final api = ref.read(apiServiceProvider);
final activeConversation = ref.read(activeConversationProvider);
if (api == null || activeConversation == null) {
_stopRemoteTaskMonitor();
return;
}
_taskStatusCheckInFlight = true;
try {
final taskIds = await api.getTaskIdsByChat(activeConversation.id);
if (taskIds.isEmpty) {
if (_observedRemoteTask && _hasStreamingAssistant) {
finishStreaming();
} else if (!_observedRemoteTask) {
// No tasks reported yet; keep monitoring to allow registration.
}
} else {
_observedRemoteTask = true;
}
} catch (err, stack) {
DebugLogger.log('Task status poll failed: $err', scope: 'chat/provider');
debugPrintStack(stackTrace: stack);
} finally {
_taskStatusCheckInFlight = false;
}
}
void _ensureFormatterForMessage(ChatMessage message) {
// If we're switching to a different message, clear the old formatter first
if (_markdownFormatter != null && _activeStreamingMessageId != message.id) {
DebugLogger.log(
'Clearing formatter for message switch: $_activeStreamingMessageId -> ${message.id}',
scope: 'chat/providers',
);
_clearStreamingFormatter();
}
// If formatter already exists for this message, reuse it
if (_markdownFormatter != null && _activeStreamingMessageId == message.id) {
return;
}
// Create new formatter
final formatter = MarkdownStreamFormatter();
// Only seed with existing content if this is a resume scenario
// For new messages (empty content), start fresh to avoid duplication
final seed = _stripStreamingPlaceholders(message.content);
if (seed.isNotEmpty && message.content.isNotEmpty) {
DebugLogger.log(
'Seeding formatter with existing content (${seed.length} chars) for message ${message.id}',
scope: 'chat/providers',
);
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;
}
2025-09-05 23:08:23 +05:30
void _touchStreamingActivity() {
2025-09-27 16:34:37 +05:30
_lastStreamingActivity = DateTime.now();
if (_hasStreamingAssistant) {
// Reset observed flag each time a new streaming session starts.
if (_taskStatusTimer == null) {
_observedRemoteTask = false;
2025-09-05 23:08:23 +05:30
}
_ensureRemoteTaskMonitor();
} else {
_stopRemoteTaskMonitor();
2025-09-05 23:08:23 +05:30
}
}
2025-09-27 16:34:37 +05:30
// 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();
}
}
}
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
}
}
}
void setMessageStream(StreamingResponseController controller) {
2025-08-10 01:20:45 +05:30
_cancelMessageStream();
_messageStream = controller;
2025-08-10 01:20:45 +05:30
}
2025-09-26 01:38:00 +05:30
void setSocketSubscriptions(
2025-09-29 00:22:12 +05:30
List<VoidCallback> subscriptions, {
2025-09-26 01:38:00 +05:30
VoidCallback? onDispose,
}) {
cancelSocketSubscriptions();
_socketSubscriptions.addAll(subscriptions);
_socketTeardown = onDispose;
}
void cancelSocketSubscriptions() {
if (_socketSubscriptions.isEmpty) {
_socketTeardown?.call();
_socketTeardown = null;
return;
}
2025-09-29 00:22:12 +05:30
for (final dispose in _socketSubscriptions) {
2025-09-26 01:38:00 +05:30
try {
2025-09-29 00:22:12 +05:30
dispose();
2025-09-26 01:38:00 +05:30
} 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;
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: _stripStreamingPlaceholders(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);
2025-09-27 16:57:42 +05:30
if (index == -1) return;
2025-09-25 18:25:39 +05:30
final original = state[index];
final updated = updater(original);
if (identical(updated, original)) {
return;
}
final next = [...state];
next[index] = updated;
state = next;
}
// Archive the last assistant message's current content as a previous version
// and clear it to prepare for regeneration, keeping the same message id.
void archiveLastAssistantAsVersion() {
if (state.isEmpty) return;
final last = state.last;
if (last.role != 'assistant') return;
// Do not archive if it's already streaming (nothing final to archive)
if (last.isStreaming) return;
final snapshot = ChatMessageVersion(
id: last.id,
content: last.content,
timestamp: last.timestamp,
model: last.model,
files: last.files == null
? null
: List<Map<String, dynamic>>.from(last.files!),
sources: List<ChatSourceReference>.from(last.sources),
followUps: List<String>.from(last.followUps),
codeExecutions: List<ChatCodeExecution>.from(last.codeExecutions),
usage: last.usage == null ? null : Map<String, dynamic>.from(last.usage!),
);
final updated = last.copyWith(
// Start a fresh stream for the new generation
isStreaming: true,
content: '',
files: null,
followUps: const [],
codeExecutions: const [],
sources: const [],
usage: null,
versions: [...last.versions, snapshot],
);
state = [...state.sublist(0, state.length - 1), updated];
_touchStreamingActivity();
}
2025-09-25 18:25:39 +05:30
void appendStatusUpdate(String messageId, ChatStatusUpdate update) {
2025-09-28 15:58:46 +05:30
final withTimestamp = update.occurredAt == null
? update.copyWith(occurredAt: DateTime.now())
: update;
2025-09-25 18:25:39 +05:30
updateMessageById(messageId, (current) {
2025-09-28 15:58:46 +05:30
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);
2025-09-25 18:25:39 +05:30
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
DebugLogger.log(
'Ignoring late chunk for finished message: ${lastMessage.id}',
scope: 'chat/providers',
);
return;
}
2025-08-10 01:20:45 +05:30
_ensureFormatterForMessage(lastMessage);
// Defensive check: ensure the formatter is for the correct message
// This prevents cross-message pollution when messages change rapidly
if (_activeStreamingMessageId != lastMessage.id) {
DebugLogger.warning(
'Formatter message ID mismatch: active=$_activeStreamingMessageId, last=${lastMessage.id}. Resetting formatter.',
);
_clearStreamingFormatter();
_ensureFormatterForMessage(lastMessage);
}
final formatter = _markdownFormatter!;
final preview = formatter.ingest(content);
2025-08-10 01:20:45 +05:30
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: preview),
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 replaceLastMessageContent(String content) {
if (state.isEmpty) {
return;
}
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
return;
}
_ensureFormatterForMessage(lastMessage);
// Defensive check: ensure the formatter is for the correct message
if (_activeStreamingMessageId != lastMessage.id) {
DebugLogger.warning(
'Formatter message ID mismatch in replace: active=$_activeStreamingMessageId, last=${lastMessage.id}. Resetting formatter.',
);
_clearStreamingFormatter();
_ensureFormatterForMessage(lastMessage);
}
final formatter = _markdownFormatter!;
final sanitized = formatter.replace(_stripStreamingPlaceholders(content));
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() {
2025-09-27 16:57:42 +05:30
if (state.isEmpty) return;
2025-08-10 01:20:45 +05:30
final lastMessage = state.last;
2025-09-27 16:57:42 +05:30
if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return;
2025-08-10 01:20:45 +05:30
final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content);
final cleaned = _stripStreamingPlaceholders(finalized);
2025-08-31 14:02:44 +05:30
var updatedLast = lastMessage.copyWith(
isStreaming: false,
content: cleaned,
);
// Fallback: if there is an immediately previous assistant message
// marked as an archived variant and we have no versions yet, attach it
// as a version so the UI shows a switcher.
if (state.length >= 2 && updatedLast.versions.isEmpty) {
final prev = state[state.length - 2];
final isArchivedAssistant =
prev.role == 'assistant' &&
(prev.metadata?['archivedVariant'] == true);
if (isArchivedAssistant) {
final snapshot = ChatMessageVersion(
id: prev.id,
content: prev.content,
timestamp: prev.timestamp,
model: prev.model,
files: prev.files,
sources: prev.sources,
followUps: prev.followUps,
codeExecutions: prev.codeExecutions,
usage: prev.usage,
);
updatedLast = updatedLast.copyWith(
versions: [...updatedLast.versions, snapshot],
);
}
}
state = [...state.sublist(0, state.length - 1), updatedLast];
_messageStream = null;
_stopRemoteTaskMonitor();
2025-09-07 18:51:59 +05:30
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation != null) {
final updatedActive = activeConversation.copyWith(
messages: List<ChatMessage>.unmodifiable(state),
updatedAt: DateTime.now(),
);
ref.read(activeConversationProvider.notifier).set(updatedActive);
final conversationsAsync = ref.read(conversationsProvider);
Conversation? summary;
conversationsAsync.maybeWhen(
data: (conversations) {
for (final conversation in conversations) {
if (conversation.id == updatedActive.id) {
summary = conversation;
break;
}
}
},
orElse: () {},
);
final updatedSummary =
(summary ?? updatedActive.copyWith(messages: const [])).copyWith(
updatedAt: updatedActive.updatedAt,
);
ref
.read(conversationsProvider.notifier)
.upsertConversation(updatedSummary.copyWith(messages: const []));
}
2025-09-07 18:51:59 +05:30
// Trigger a refresh of the conversations list so UI like the Chats Drawer
// can reconcile with the server once streaming completes. Best-effort:
// ignore if ref lifecycle/context prevents invalidation.
2025-09-07 18:51:59 +05:30
try {
refreshConversationsCache(ref);
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();
// Clear context attachments (web pages, YouTube, knowledge base docs)
ref.read(contextAttachmentsProvider.notifier).clear();
2025-08-10 01:20:45 +05:30
}
// 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 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;
}
List<Map<String, dynamic>> _contextAttachmentsToFiles(
List<ChatContextAttachment> attachments,
) {
return attachments.map((attachment) {
switch (attachment.type) {
case ChatContextAttachmentType.web:
// Web pages use type 'text' with file data nested under 'file' key
return {
'type': 'text',
'name': attachment.url ?? attachment.displayName,
if (attachment.url != null) 'url': attachment.url,
if (attachment.collectionName != null)
'collection_name': attachment.collectionName,
'file': {
'data': {'content': attachment.content ?? ''},
'meta': {
'name': attachment.displayName,
if (attachment.url != null) 'source': attachment.url,
},
},
};
case ChatContextAttachmentType.youtube:
// YouTube uses type 'text' with context 'full' for full transcript
return {
'type': 'text',
'name': attachment.url ?? attachment.displayName,
if (attachment.url != null) 'url': attachment.url,
'context': 'full',
if (attachment.collectionName != null)
'collection_name': attachment.collectionName,
'file': {
'data': {'content': attachment.content ?? ''},
'meta': {
'name': attachment.displayName,
if (attachment.url != null) 'source': attachment.url,
},
},
};
case ChatContextAttachmentType.knowledge:
// Knowledge base files use type 'file' with id for lookup
final map = <String, dynamic>{
'type': 'file',
'id': attachment.fileId ?? attachment.id,
'name': attachment.displayName,
'knowledge': true,
if (attachment.collectionName != null)
'collection_name': attachment.collectionName,
if (attachment.url != null) 'source': attachment.url,
};
return map;
}
}).toList();
}
// 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, [
String? existingAssistantId,
]) 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
}
}
// Pre-seed assistant skeleton and persist chain; always use a new id so
// server history can branch like OpenWebUI.
2025-09-05 11:48:43 +05:30
final String assistantMessageId = await _preseedAssistantAndPersist(
ref,
existingAssistantId: null,
2025-09-05 11:48:43 +05:30
modelId: selectedModel.id,
2025-09-20 18:28:12 +05:30
systemPrompt: effectiveSystemPrompt,
);
2025-09-05 11:15:39 +05:30
// Attach previous assistant as a version snapshot to the new assistant
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.length >= 2) {
final prev = msgs[msgs.length - 2];
final last = msgs.last;
if (prev.role == 'assistant' && last.id == assistantMessageId) {
final snapshot = ChatMessageVersion(
id: prev.id,
content: prev.content,
timestamp: prev.timestamp,
model: prev.model,
files: prev.files,
sources: prev.sources,
followUps: prev.followUps,
codeExecutions: prev.codeExecutions,
usage: prev.usage,
);
ref
.read(chatMessagesProvider.notifier)
.updateLastMessageWithFunction(
(m) => m.copyWith(versions: [...m.versions, snapshot]),
);
}
}
} catch (_) {}
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>[],
};
// WebSocket-only streaming requires socket connection
2025-09-07 21:41:13 +05:30
final socketService = ref.read(socketServiceProvider);
if (socketService == null) {
// No socket service available
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
return m.copyWith(
content: 'Connection not available. Please try again later.',
isStreaming: false,
);
});
return;
}
// Ensure socket is connected (with 10s timeout)
if (!socketService.isConnected) {
final connected = await socketService.ensureConnected(
timeout: const Duration(seconds: 10),
);
if (!connected) {
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
return m.copyWith(
content:
'Unable to connect to server. Please check your connection and try again.',
isStreaming: false,
);
});
return;
}
2025-09-07 22:37:52 +05:30
}
2025-09-07 21:41:13 +05:30
final socketSessionId = socketService.sessionId;
final bool wantSessionBinding =
socketService.isConnected &&
(socketSessionId != null && socketSessionId.isNotEmpty);
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,
userSettings: userSettingsData,
2025-09-05 11:15:39 +05:30
);
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 (_) {}
final registerDeltaListener = createConversationDeltaRegistrar(ref);
2025-09-29 00:22:12 +05:30
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!,
2025-09-07 21:41:13 +05:30
socketService: socketService,
workerManager: ref.read(workerManagerProvider),
registerDeltaListener: registerDeltaListener,
2025-09-07 21:41:13 +05:30
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
.read(conversationsProvider.notifier)
.updateConversation(
active.id,
(conversation) => conversation.copyWith(
title: newTitle,
updatedAt: DateTime.now(),
),
);
2025-09-25 18:25:39 +05:30
}
refreshConversationsCache(ref);
2025-09-25 18:25:39 +05:30
},
onChatTagsUpdated: () {
refreshConversationsCache(ref);
2025-09-25 18:25:39 +05:30
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);
ref
.read(conversationsProvider.notifier)
.upsertConversation(refreshed.copyWith(messages: const []));
2025-09-25 18:25:39 +05:30
} 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.controller)
..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);
}
Future<void> sendMessageWithContainer(
ProviderContainer container,
String message,
List<String>? attachments, [
List<String>? toolIds,
]) async {
await _sendMessageInternal(container, 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
// Note: We only store context attachments (web/youtube/knowledge) in msg.files.
// Uploaded files are tracked via attachmentIds and will be rebuilt by
// _buildMessagePayloadWithAttachments when constructing the API payload.
// This prevents uploaded files from being duplicated in the final message.
final contextAttachments = ref.read(contextAttachmentsProvider);
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
final List<Map<String, dynamic>>? userFiles = contextFiles.isNotEmpty
? contextFiles
: null;
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
ref
.read(conversationsProvider.notifier)
.upsertConversation(
updatedConversation.copyWith(updatedAt: DateTime.now()),
);
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) {
refreshConversationsCache(ref);
2025-08-28 14:45:46 +05:30
}
} 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!,
2025-09-07 21:41:13 +05:30
role: msg.role,
cleanedText: cleaned,
attachmentIds: ids,
);
if (msg.files != null && msg.files!.isNotEmpty) {
messageMap['files'] = [
...?messageMap['files'] as List<dynamic>?,
...msg.files!,
];
}
2025-08-10 01:20:45 +05:30
conversationMessages.add(messageMap);
} else {
// Regular text-only message
final Map<String, dynamic> messageMap = {
'role': msg.role,
'content': cleaned,
};
if (msg.files != null && msg.files!.isNotEmpty) {
messageMap['files'] = msg.files;
}
conversationMessages.add(messageMap);
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>[],
};
// WebSocket-only streaming requires socket connection.
// Wait for connection with timeout before proceeding.
2025-08-31 14:02:44 +05:30
final socketService = ref.read(socketServiceProvider);
if (socketService == null) {
// No socket service available at all
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
return m.copyWith(
content: 'Connection not available. Please try again later.',
isStreaming: false,
);
});
return;
}
// Ensure socket is connected (with 10s timeout for initial connection)
if (!socketService.isConnected) {
final connected = await socketService.ensureConnected(
timeout: const Duration(seconds: 10),
);
if (!connected) {
// Socket connection failed - cannot stream without it
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
m,
) {
return m.copyWith(
content:
'Unable to connect to server. Please check your connection and try again.',
isStreaming: false,
);
});
return;
}
}
// Socket is now connected - resolve session for background tasks parity
final socketSessionId = socketService.sessionId;
2025-09-02 21:19:07 +05:30
final bool wantSessionBinding =
socketService.isConnected &&
2025-09-02 21:19:07 +05:30
(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,
userSettings: userSettingsData,
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
// Use unified streaming helper for 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 (_) {}
final registerDeltaListener = createConversationDeltaRegistrar(ref);
2025-09-29 00:22:12 +05:30
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!,
2025-09-25 12:28:02 +05:30
socketService: socketService,
workerManager: ref.read(workerManagerProvider),
registerDeltaListener: registerDeltaListener,
2025-09-25 12:28:02 +05:30
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
.read(conversationsProvider.notifier)
.updateConversation(
active.id,
(conversation) => conversation.copyWith(
title: newTitle,
updatedAt: DateTime.now(),
),
);
2025-09-25 18:25:39 +05:30
}
refreshConversationsCache(ref);
2025-09-25 18:25:39 +05:30
},
onChatTagsUpdated: () {
refreshConversationsCache(ref);
2025-09-25 18:25:39 +05:30
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);
ref
.read(conversationsProvider.notifier)
.upsertConversation(refreshed.copyWith(messages: const []));
2025-09-25 18:25:39 +05:30
} 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.controller)
..setSocketSubscriptions(
activeStream.socketSubscriptions,
onDispose: activeStream.disposeWatchdog,
);
// Clear context attachments after successfully initiating the message send.
// This prevents stale attachments from being included in subsequent messages.
try {
ref.read(contextAttachmentsProvider.notifier).clear();
} catch (_) {}
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);
refreshConversationsCache(ref);
2025-08-10 01:20:45 +05:30
} 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);
ref
.read(conversationsProvider.notifier)
.updateConversation(
conversationId,
(conversation) =>
conversation.copyWith(pinned: pinned, updatedAt: DateTime.now()),
);
2025-08-10 01:20:45 +05:30
// Refresh conversations list to reflect the change
refreshConversationsCache(ref);
2025-08-10 01:20:45 +05:30
// 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);
ref
.read(conversationsProvider.notifier)
.updateConversation(
conversationId,
(conversation) => conversation.copyWith(
archived: archived,
updatedAt: DateTime.now(),
),
);
2025-08-10 01:20:45 +05:30
// Refresh conversations list to reflect the change
refreshConversationsCache(ref);
2025-08-10 01:20:45 +05:30
} 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);
ref
.read(conversationsProvider.notifier)
.updateConversation(
conversationId,
(conversation) => conversation.copyWith(
shareId: shareId,
updatedAt: DateTime.now(),
),
);
2025-08-10 01:20:45 +05:30
// Refresh conversations list to reflect the change
refreshConversationsCache(ref);
2025-08-10 01:20:45 +05:30
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
.read(conversationsProvider.notifier)
.upsertConversation(
clonedConversation.copyWith(updatedAt: DateTime.now()),
);
refreshConversationsCache(ref);
2025-08-10 01:20:45 +05:30
} 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;
// Mark previous assistant as an archived variant so UI can hide it
final notifier = ref.read(chatMessagesProvider.notifier);
if (lastAssistantMessage != null) {
notifier.updateLastMessageWithFunction((m) {
final meta = Map<String, dynamic>.from(m.metadata ?? const {});
meta['archivedVariant'] = true;
// Keep content/files intact for server persistence
return m.copyWith(metadata: meta, isStreaming: false);
});
}
2025-08-10 01:20:45 +05:30
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 if active
2025-09-01 20:26:29 +05:30
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;
}