Files
iiEsaywebUIapp/lib/core/providers/app_providers.dart

1350 lines
42 KiB
Dart
Raw Normal View History

2025-08-10 01:20:45 +05:30
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/storage_service.dart';
// (removed duplicate) import '../services/optimized_storage_service.dart';
import '../services/api_service.dart';
import '../auth/auth_state_manager.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../services/attachment_upload_queue.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/model.dart';
import '../models/conversation.dart';
2025-08-17 16:11:19 +05:30
import '../models/chat_message.dart';
2025-08-17 00:05:30 +05:30
import '../models/folder.dart';
2025-08-10 01:20:45 +05:30
import '../models/user_settings.dart';
import '../models/file_info.dart';
import '../models/knowledge_base.dart';
import '../services/settings_service.dart';
2025-08-10 01:20:45 +05:30
import '../services/optimized_storage_service.dart';
2025-08-31 14:02:44 +05:30
import '../services/socket_service.dart';
2025-08-20 22:15:26 +05:30
import '../utils/debug_logger.dart';
2025-08-10 01:20:45 +05:30
// Storage providers
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError();
});
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
// Single, shared instance with explicit platform options
return const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
sharedPreferencesName: 'conduit_secure_prefs',
preferencesKeyPrefix: 'conduit_',
// Avoid auto-wipe on transient errors; we handle errors in code
resetOnError: false,
),
iOptions: IOSOptions(
accountName: 'conduit_secure_storage',
synchronizable: false,
),
);
2025-08-10 01:20:45 +05:30
});
final storageServiceProvider = Provider<StorageService>((ref) {
return StorageService(
secureStorage: ref.watch(secureStorageProvider),
prefs: ref.watch(sharedPreferencesProvider),
);
});
// Optimized storage service provider
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
ref,
) {
return OptimizedStorageService(
secureStorage: ref.watch(secureStorageProvider),
prefs: ref.watch(sharedPreferencesProvider),
);
});
// Theme provider
2025-09-21 22:31:44 +05:30
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(
ThemeModeNotifier.new,
);
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
class ThemeModeNotifier extends Notifier<ThemeMode> {
late final OptimizedStorageService _storage;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
@override
ThemeMode build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final storedMode = _storage.getThemeMode();
if (storedMode != null) {
return ThemeMode.values.firstWhere(
(e) => e.toString() == storedMode,
2025-08-10 01:20:45 +05:30
orElse: () => ThemeMode.system,
);
}
2025-09-21 22:31:44 +05:30
return ThemeMode.system;
2025-08-10 01:20:45 +05:30
}
void setTheme(ThemeMode mode) {
state = mode;
_storage.setThemeMode(mode.toString());
}
}
// Locale provider
2025-09-21 22:31:44 +05:30
final localeProvider = NotifierProvider<LocaleNotifier, Locale?>(
LocaleNotifier.new,
);
2025-09-21 22:31:44 +05:30
class LocaleNotifier extends Notifier<Locale?> {
late final OptimizedStorageService _storage;
2025-09-21 22:31:44 +05:30
@override
Locale? build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final code = _storage.getLocaleCode();
if (code != null && code.isNotEmpty) {
2025-09-21 22:31:44 +05:30
return Locale(code);
}
2025-09-21 22:31:44 +05:30
return null; // system default
}
Future<void> setLocale(Locale? locale) async {
state = locale;
await _storage.setLocaleCode(locale?.languageCode);
}
}
2025-08-10 01:20:45 +05:30
// Server connection providers - optimized with caching
final serverConfigsProvider = FutureProvider<List<ServerConfig>>((ref) async {
final storage = ref.watch(optimizedStorageServiceProvider);
return storage.getServerConfigs();
});
final activeServerProvider = FutureProvider<ServerConfig?>((ref) async {
final storage = ref.watch(optimizedStorageServiceProvider);
final configs = await ref.watch(serverConfigsProvider.future);
final activeId = await storage.getActiveServerId();
if (activeId == null || configs.isEmpty) return null;
2025-09-23 00:58:58 +05:30
for (final config in configs) {
if (config.id == activeId) {
return config;
}
}
return null;
2025-08-10 01:20:45 +05:30
});
final serverConnectionStateProvider = Provider<bool>((ref) {
final activeServer = ref.watch(activeServerProvider);
return activeServer.maybeWhen(
data: (server) => server != null,
orElse: () => false,
);
});
// API Service provider with unified auth integration
final apiServiceProvider = Provider<ApiService?>((ref) {
// If reviewer mode is enabled, skip creating ApiService
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
return null;
}
final activeServer = ref.watch(activeServerProvider);
return activeServer.maybeWhen(
data: (server) {
if (server == null) return null;
final apiService = ApiService(
serverConfig: server,
authToken: null, // Will be set by auth state manager
);
// Keep callbacks in sync so interceptor can notify auth manager
apiService.setAuthCallbacks(
onAuthTokenInvalid: () {},
onTokenInvalidated: () async {
final authManager = ref.read(authStateManagerProvider.notifier);
await authManager.onTokenInvalidated();
},
);
// Set up callback for unified auth state manager
// (legacy properties kept during transition)
apiService.onTokenInvalidated = () async {
final authManager = ref.read(authStateManagerProvider.notifier);
await authManager.onTokenInvalidated();
};
// Keep legacy callback for backward compatibility during transition
apiService.onAuthTokenInvalid = () {
// This will be removed once migration is complete
2025-09-25 22:36:42 +05:30
DebugLogger.auth('legacy-token-callback', scope: 'auth/api');
2025-08-10 01:20:45 +05:30
};
return apiService;
},
orElse: () => null,
);
});
2025-08-31 14:02:44 +05:30
// Socket.IO service provider
final socketServiceProvider = Provider<SocketService?>((ref) {
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) return null;
final activeServer = ref.watch(activeServerProvider);
2025-09-23 13:43:01 +05:30
final token = ref.watch(authTokenProvider3.select((t) => t));
final transportMode = ref.watch(
appSettingsProvider.select((s) => s.socketTransportMode),
);
2025-08-31 14:02:44 +05:30
return activeServer.maybeWhen(
data: (server) {
if (server == null) return null;
final s = SocketService(
serverConfig: server,
authToken: token,
websocketOnly: transportMode == 'ws',
);
2025-08-31 14:02:44 +05:30
// best-effort connect; errors handled internally
// ignore unawaited_futures
s.connect();
2025-09-23 13:43:01 +05:30
// Keep socket token up-to-date without reconstructing the service
ref.listen<String?>(authTokenProvider3, (prev, next) {
s.updateAuthToken(next);
});
2025-09-02 21:19:07 +05:30
ref.onDispose(() {
2025-09-16 18:15:44 +05:30
try {
s.dispose();
} catch (_) {}
2025-09-02 21:19:07 +05:30
});
2025-08-31 14:02:44 +05:30
return s;
},
orElse: () => null,
);
});
2025-08-10 01:20:45 +05:30
// Attachment upload queue provider
final attachmentUploadQueueProvider = Provider<AttachmentUploadQueue?>((ref) {
final api = ref.watch(apiServiceProvider);
if (api == null) return null;
final queue = AttachmentUploadQueue();
// Initialize once; subsequent calls are no-ops due to singleton
queue.initialize(
onUpload: (filePath, fileName) => api.uploadFile(filePath, fileName),
);
return queue;
});
// Auth providers
// Auth token integration with API service - using unified auth system
final apiTokenUpdaterProvider = Provider<void>((ref) {
// Listen to unified auth token changes and update API service
2025-09-23 13:43:01 +05:30
ref.listen<String?>(authTokenProvider3, (previous, next) {
2025-08-10 01:20:45 +05:30
final api = ref.read(apiServiceProvider);
2025-09-23 13:43:01 +05:30
if (api != null) {
2025-09-24 10:52:15 +05:30
api.updateAuthToken(next);
final length = next?.length ?? 0;
2025-09-25 22:36:42 +05:30
DebugLogger.auth(
'token-updated',
scope: 'auth/api',
data: {'length': length},
);
2025-08-10 01:20:45 +05:30
}
});
});
final currentUserProvider = FutureProvider<User?>((ref) async {
final api = ref.read(apiServiceProvider);
final isAuthenticated = ref.watch(isAuthenticatedProvider2);
if (api == null || !isAuthenticated) return null;
try {
return await api.getCurrentUser();
} catch (e) {
return null;
}
});
// Helper provider to force refresh auth state - now using unified system
final refreshAuthStateProvider = Provider<void>((ref) {
// This provider can be invalidated to force refresh the unified auth system
2025-08-29 12:58:56 +05:30
Future.microtask(() => ref.read(authActionsProvider).refresh());
2025-08-10 01:20:45 +05:30
return;
});
// Model providers
final modelsProvider = FutureProvider<List<Model>>((ref) async {
// Reviewer mode returns mock models
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
return [
const Model(
id: 'demo/gemma-2-mini',
name: 'Gemma 2 Mini (Demo)',
description: 'Demo model for reviewer mode',
isMultimodal: true,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
const Model(
id: 'demo/llama-3-8b',
name: 'Llama 3 8B (Demo)',
description: 'Fast text model for demo',
isMultimodal: false,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
2025-09-25 22:36:42 +05:30
DebugLogger.log('fetch-start', scope: 'models');
2025-08-10 01:20:45 +05:30
final models = await api.getModels();
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-ok',
scope: 'models',
data: {'count': models.length},
);
2025-08-10 01:20:45 +05:30
return models;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('fetch-failed', scope: 'models', error: e);
2025-08-10 01:20:45 +05:30
// If models endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('endpoint-403', scope: 'models');
2025-08-10 01:20:45 +05:30
}
return [];
}
});
2025-09-21 22:31:44 +05:30
final selectedModelProvider = NotifierProvider<SelectedModelNotifier, Model?>(
SelectedModelNotifier.new,
);
2025-08-10 01:20:45 +05:30
2025-08-17 17:43:19 +05:30
// Track if the current model selection is manual (user-selected) or automatic (default)
2025-09-21 22:31:44 +05:30
final isManualModelSelectionProvider =
NotifierProvider<IsManualModelSelectionNotifier, bool>(
IsManualModelSelectionNotifier.new,
);
class SelectedModelNotifier extends Notifier<Model?> {
@override
Model? build() => null;
void set(Model? model) => state = model;
void clear() => state = null;
}
class IsManualModelSelectionNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
2025-08-17 17:43:19 +05:30
// Listen for settings changes and reset manual selection when default model changes
final _settingsWatcherProvider = Provider<void>((ref) {
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
if (previous?.defaultModel != next.defaultModel) {
// Reset manual selection when default model changes
2025-09-21 22:31:44 +05:30
ref.read(isManualModelSelectionProvider.notifier).set(false);
2025-08-17 17:43:19 +05:30
}
});
});
2025-08-28 19:17:05 +05:30
// Auto-apply default model from settings when it changes (and not manually overridden)
final defaultModelAutoSelectionProvider = Provider<void>((ref) {
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
// Only react when default model value changes
if (previous?.defaultModel == next.defaultModel) return;
// Do not override manual selections
if (ref.read(isManualModelSelectionProvider)) return;
final desired = next.defaultModel;
if (desired == null || desired.isEmpty) return;
2025-09-01 18:49:43 +05:30
// Resolve the desired model against available models (by ID only)
2025-08-28 19:17:05 +05:30
Future(() async {
try {
// Prefer already-loaded models to avoid unnecessary fetches
List<Model> models;
final modelsAsync = ref.read(modelsProvider);
if (modelsAsync.hasValue) {
models = modelsAsync.value!;
} else {
models = await ref.read(modelsProvider.future);
}
Model? selected;
try {
2025-09-01 18:49:43 +05:30
selected = models.firstWhere((model) => model.id == desired);
} catch (_) {
selected = null;
}
2025-08-28 19:17:05 +05:30
// Fallback: keep current selection or pick first available
2025-09-16 18:15:44 +05:30
selected ??=
ref.read(selectedModelProvider) ??
2025-08-28 19:17:05 +05:30
(models.isNotEmpty ? models.first : null);
if (selected != null) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(selected);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'auto-apply',
scope: 'models/default',
data: {'name': selected.name},
2025-08-28 19:17:05 +05:30
);
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'auto-select-failed',
scope: 'models/default',
error: e,
2025-08-28 19:17:05 +05:30
);
}
});
});
});
2025-08-17 16:11:19 +05:30
// Cache timestamp for conversations to prevent rapid re-fetches
2025-09-21 22:31:44 +05:30
final _conversationsCacheTimestamp =
NotifierProvider<_ConversationsCacheTimestampNotifier, DateTime?>(
_ConversationsCacheTimestampNotifier.new,
);
class _ConversationsCacheTimestampNotifier extends Notifier<DateTime?> {
@override
DateTime? build() => null;
void set(DateTime? timestamp) => state = timestamp;
}
2025-08-17 16:11:19 +05:30
// Conversation providers - Now using correct OpenWebUI API with caching
2025-08-10 01:20:45 +05:30
final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
2025-08-17 16:11:19 +05:30
// Check if we have a recent cache (within 5 seconds)
final lastFetch = ref.read(_conversationsCacheTimestamp);
if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) {
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'cache-hit',
scope: 'conversations',
data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds},
);
2025-08-17 16:11:19 +05:30
// Note: Can't read our own provider here, would cause a cycle
// The caching is handled by Riverpod's built-in mechanism
}
2025-08-10 01:20:45 +05:30
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
// Provide a simple local demo conversation list
return [
Conversation(
id: 'demo-conv-1',
title: 'Welcome to Conduit (Demo)',
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
2025-08-17 16:11:19 +05:30
messages: [
ChatMessage(
id: 'demo-msg-1',
role: 'assistant',
content:
'**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.',
2025-08-17 16:11:19 +05:30
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
model: 'Gemma 2 Mini (Demo)',
isStreaming: false,
),
],
2025-08-10 01:20:45 +05:30
),
];
}
final api = ref.watch(apiServiceProvider);
if (api == null) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('api-missing', scope: 'conversations');
2025-08-10 01:20:45 +05:30
return [];
}
try {
2025-09-25 22:36:42 +05:30
DebugLogger.log('fetch-start', scope: 'conversations');
final conversations = await api
.getConversations(); // Fetch all conversations
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'fetch-ok',
scope: 'conversations',
data: {'count': conversations.length},
2025-08-10 01:20:45 +05:30
);
// Also fetch folder information and update conversations with folder IDs
try {
final foldersData = await api.getFolders();
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'folders-fetched',
scope: 'conversations',
data: {'count': foldersData.length},
);
// Parse folder data into Folder objects
final folders = foldersData
.map((folderData) => Folder.fromJson(folderData))
.toList();
// Create a map of conversation ID to folder ID
final conversationToFolder = <String, String>{};
for (final folder in folders) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'folder',
scope: 'conversations/map',
data: {
'id': folder.id,
'name': folder.name,
'count': folder.conversationIds.length,
},
);
for (final conversationId in folder.conversationIds) {
conversationToFolder[conversationId] = folder.id;
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'map',
scope: 'conversations/map',
data: {'conversationId': conversationId, 'folderId': folder.id},
);
2025-08-17 16:11:19 +05:30
}
}
// Update conversations with folder IDs, preferring explicit folder_id from chat if present
// Use a map to ensure uniqueness by ID throughout the merge process
final conversationMap = <String, Conversation>{};
for (final conversation in conversations) {
// Prefer server-provided folderId on the chat itself
final explicitFolderId = conversation.folderId;
final mappedFolderId = conversationToFolder[conversation.id];
final folderIdToUse = explicitFolderId ?? mappedFolderId;
if (folderIdToUse != null) {
conversationMap[conversation.id] = conversation.copyWith(
folderId: folderIdToUse,
);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'update-folder',
scope: 'conversations/map',
data: {
'conversationId': conversation.id,
'folderId': folderIdToUse,
'explicit': explicitFolderId != null,
},
);
2025-08-17 00:05:30 +05:30
} else {
conversationMap[conversation.id] = conversation;
2025-08-17 00:05:30 +05:30
}
}
// Merge conversations that are in folders but missing from the main list
// Build a set of existing IDs from the fetched list
final existingIds = conversationMap.keys.toSet();
2025-08-17 16:11:19 +05:30
// Diagnostics: count how many folder-mapped IDs are missing from the main list
final missingInBase = conversationToFolder.keys
.where((id) => !existingIds.contains(id))
.toList();
if (missingInBase.isNotEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning(
'missing-in-base',
scope: 'conversations/map',
data: {
'count': missingInBase.length,
'preview': missingInBase.take(5).toList(),
},
);
} else {
2025-09-25 22:36:42 +05:30
DebugLogger.log('folders-synced', scope: 'conversations/map');
}
// Attempt to fetch missing conversations per-folder to construct accurate entries
// If per-folder fetch fails, fall back to creating minimal placeholder entries
final apiSvc = ref.read(apiServiceProvider);
for (final folder in folders) {
// Collect IDs in this folder that are missing
final missingIds = folder.conversationIds
.where((id) => !existingIds.contains(id))
.toList();
if (missingIds.isEmpty) continue;
List<Conversation> folderConvs = const [];
try {
if (apiSvc != null) {
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
2025-08-17 16:11:19 +05:30
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'folder-fetch-failed',
scope: 'conversations/map',
error: e,
data: {'folderId': folder.id},
);
}
2025-08-17 16:11:19 +05:30
// Index fetched folder conversations for quick lookup
final fetchedMap = {for (final c in folderConvs) c.id: c};
for (final convId in missingIds) {
final fetched = fetchedMap[convId];
if (fetched != null) {
final toAdd = fetched.folderId == null
? fetched.copyWith(folderId: folder.id)
: fetched;
// Use map to prevent duplicates - this will overwrite if ID already exists
conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'add-missing',
scope: 'conversations/map',
data: {'conversationId': toAdd.id, 'folderId': folder.id},
);
} else {
// Create a minimal placeholder if not returned by folder API
final placeholder = Conversation(
id: convId,
title: 'Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
messages: const [],
folderId: folder.id,
);
// Use map to prevent duplicates
conversationMap[convId] = placeholder;
existingIds.add(convId);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'add-placeholder',
scope: 'conversations/map',
data: {'conversationId': convId, 'folderId': folder.id},
);
2025-08-17 00:05:30 +05:30
}
}
}
2025-08-17 16:11:19 +05:30
// Convert map back to list - this ensures no duplicates by ID
final sortedConversations = conversationMap.values.toList();
2025-08-17 00:26:12 +05:30
// Sort conversations by updatedAt in descending order (most recent first)
2025-08-17 16:11:19 +05:30
sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'sort',
scope: 'conversations',
data: {'source': 'folder-sync'},
);
2025-08-17 16:11:19 +05:30
// Update cache timestamp
2025-09-21 22:31:44 +05:30
ref.read(_conversationsCacheTimestamp.notifier).set(DateTime.now());
2025-08-17 16:11:19 +05:30
return sortedConversations;
2025-08-17 00:05:30 +05:30
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'folders-fetch-failed',
scope: 'conversations',
error: e,
);
2025-08-17 00:26:12 +05:30
// Sort conversations even when folder fetch fails
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'sort',
scope: 'conversations',
data: {'source': 'fallback'},
);
2025-08-17 16:11:19 +05:30
// Update cache timestamp
2025-09-21 22:31:44 +05:30
ref.read(_conversationsCacheTimestamp.notifier).set(DateTime.now());
2025-08-17 00:05:30 +05:30
return conversations; // Return original conversations if folder fetch fails
}
2025-08-10 01:20:45 +05:30
} catch (e, stackTrace) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'fetch-failed',
scope: 'conversations',
error: e,
stackTrace: stackTrace,
);
2025-08-10 01:20:45 +05:30
// If conversations endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('endpoint-403', scope: 'conversations');
2025-08-10 01:20:45 +05:30
}
// Return empty list instead of re-throwing to allow app to continue functioning
return [];
}
});
2025-09-21 22:31:44 +05:30
final activeConversationProvider =
NotifierProvider<ActiveConversationNotifier, Conversation?>(
ActiveConversationNotifier.new,
);
class ActiveConversationNotifier extends Notifier<Conversation?> {
@override
Conversation? build() => null;
void set(Conversation? conversation) => state = conversation;
void clear() => state = null;
}
2025-08-10 01:20:45 +05:30
// Provider to load full conversation with messages
final loadConversationProvider = FutureProvider.family<Conversation, String>((
ref,
conversationId,
) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
throw Exception('No API service available');
}
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'load-start',
scope: 'conversation',
data: {'id': conversationId},
);
2025-08-10 01:20:45 +05:30
final fullConversation = await api.getConversation(conversationId);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'load-ok',
scope: 'conversation',
data: {'messages': fullConversation.messages.length},
2025-08-10 01:20:45 +05:30
);
return fullConversation;
});
// Provider to automatically load and set the default model from user settings or OpenWebUI
2025-08-10 01:20:45 +05:30
final defaultModelProvider = FutureProvider<Model?>((ref) async {
2025-08-28 18:54:06 +05:30
// Initialize the settings watcher (side-effect only)
ref.read(_settingsWatcherProvider);
// Read settings without subscribing to rebuilds to avoid watch/await hazards
final reviewerMode = ref.read(reviewerModeProvider);
if (reviewerMode) {
2025-08-17 17:43:19 +05:30
// Check if a model is manually selected
final currentSelected = ref.read(selectedModelProvider);
2025-08-17 17:43:19 +05:30
final isManualSelection = ref.read(isManualModelSelectionProvider);
2025-08-20 22:15:26 +05:30
2025-08-17 17:43:19 +05:30
if (currentSelected != null && isManualSelection) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'manual',
scope: 'models/default',
data: {'name': currentSelected.name},
);
return currentSelected;
}
// Get demo models and select the first one
final models = await ref.read(modelsProvider.future);
if (models.isNotEmpty) {
final defaultModel = models.first;
2025-08-28 18:54:06 +05:30
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(defaultModel);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'auto-select',
scope: 'models/default',
data: {'name': defaultModel.name},
2025-08-28 18:54:06 +05:30
);
}
return defaultModel;
}
return null;
}
2025-08-28 18:54:06 +05:30
final api = ref.read(apiServiceProvider);
2025-08-10 01:20:45 +05:30
if (api == null) return null;
try {
2025-09-16 20:10:53 +05:30
// Respect manual selection if present
if (ref.read(isManualModelSelectionProvider)) {
final current = ref.read(selectedModelProvider);
if (current != null) return current;
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
// 1) Fast path: read stored default model ID directly and select optimistically
try {
final storedDefaultId = await SettingsService.getDefaultModel();
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
if (!ref.read(isManualModelSelectionProvider)) {
final placeholder = Model(
id: storedDefaultId,
name: storedDefaultId,
supportsStreaming: true,
2025-09-01 18:49:43 +05:30
);
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(placeholder);
2025-09-01 18:49:43 +05:30
}
2025-09-16 20:10:53 +05:30
// Reconcile against real models in background
Future.microtask(() async {
try {
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == storedDefaultId);
} catch (_) {
final byName = models
.where((m) => m.name == storedDefaultId)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'reconcile',
scope: 'models/default',
data: {'name': resolved.name, 'source': 'stored'},
2025-09-01 18:49:43 +05:30
);
}
2025-09-16 20:10:53 +05:30
} catch (_) {}
});
return ref.read(selectedModelProvider);
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
} catch (_) {}
2025-08-10 01:20:45 +05:30
2025-09-16 20:10:53 +05:30
// 2) Fast server path: query server default ID without listing all models
2025-08-10 01:20:45 +05:30
try {
2025-09-16 20:10:53 +05:30
final serverDefault = await api.getDefaultModel();
if (serverDefault != null && serverDefault.isNotEmpty) {
2025-08-28 18:54:06 +05:30
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-16 20:10:53 +05:30
final placeholder = Model(
id: serverDefault,
name: serverDefault,
supportsStreaming: true,
2025-08-28 18:54:06 +05:30
);
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(placeholder);
2025-08-28 18:54:06 +05:30
}
2025-09-16 20:10:53 +05:30
// Reconcile against real models in background
Future.microtask(() async {
try {
final models = await ref.read(modelsProvider.future);
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == serverDefault);
} catch (_) {
final byName = models
.where((m) => m.name == serverDefault)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'reconcile',
scope: 'models/default',
data: {'name': resolved.name, 'source': 'server'},
2025-09-16 20:10:53 +05:30
);
}
} catch (_) {}
});
return ref.read(selectedModelProvider);
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
} catch (_) {}
// 3) Fallback: fetch models and pick first available
final models = await ref.read(modelsProvider.future);
if (models.isEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('no-models', scope: 'models/default');
2025-09-16 20:10:53 +05:30
return null;
}
final selectedModel = models.first;
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(selectedModel);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fallback',
scope: 'models/default',
data: {'name': selectedModel.name},
);
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
return selectedModel;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('set-default-failed', scope: 'models/default', error: e);
2025-08-10 01:20:45 +05:30
return null;
}
});
// Background model loading provider that doesn't block UI
// This just schedules the loading, doesn't wait for it
final backgroundModelLoadProvider = Provider<void>((ref) {
// Ensure API token updater is initialized
ref.watch(apiTokenUpdaterProvider);
// Schedule background loading without blocking
Future.microtask(() async {
// Wait a bit to ensure auth is complete
2025-09-16 20:10:53 +05:30
await Future.delayed(const Duration(milliseconds: 200));
2025-08-10 01:20:45 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log('bg-start', scope: 'models/background');
2025-08-10 01:20:45 +05:30
// Load default model in background
try {
await ref.read(defaultModelProvider.future);
2025-09-25 22:36:42 +05:30
DebugLogger.log('bg-complete', scope: 'models/background');
2025-08-10 01:20:45 +05:30
} catch (e) {
// Ignore errors in background loading
2025-09-25 22:36:42 +05:30
DebugLogger.error('bg-failed', scope: 'models/background', error: e);
2025-08-10 01:20:45 +05:30
}
});
// Return immediately, don't block the UI
return;
});
// Search query provider
2025-09-21 22:31:44 +05:30
final searchQueryProvider = NotifierProvider<SearchQueryNotifier, String>(
SearchQueryNotifier.new,
);
class SearchQueryNotifier extends Notifier<String> {
@override
String build() => '';
void set(String query) => state = query;
}
2025-08-10 01:20:45 +05:30
// Server-side search provider for chats
final serverSearchProvider = FutureProvider.family<List<Conversation>, String>((
ref,
query,
) async {
if (query.trim().isEmpty) {
// Return empty list for empty query instead of all conversations
return [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
2025-09-25 22:36:42 +05:30
final trimmedQuery = query.trim();
DebugLogger.log(
'server-search',
scope: 'search',
data: {'length': trimmedQuery.length},
);
2025-08-10 01:20:45 +05:30
// Use the new server-side search API
final chatHits = await api.searchChats(
2025-09-25 22:36:42 +05:30
query: trimmedQuery,
2025-08-10 01:20:45 +05:30
archived: false, // Only search non-archived conversations
limit: 50,
sortBy: 'updated_at',
sortOrder: 'desc',
);
// chatHits is already List<Conversation>
final List<Conversation> conversations = List.of(chatHits);
2025-08-10 01:20:45 +05:30
// Perform message-level search and merge chat hits
try {
final messageHits = await api.searchMessages(
2025-09-25 22:36:42 +05:30
query: trimmedQuery,
limit: 100,
);
// Build a set of conversation IDs already present from chat search
final existingIds = conversations.map((c) => c.id).toSet();
// Extract chat ids from message hits (supporting multiple key casings)
final messageChatIds = <String>{};
for (final hit in messageHits) {
final chatId =
(hit['chat_id'] ?? hit['chatId'] ?? hit['chatID']) as String?;
if (chatId != null && chatId.isNotEmpty) {
messageChatIds.add(chatId);
}
}
2025-08-10 01:20:45 +05:30
// Determine which chat ids we still need to fetch
final idsToFetch = messageChatIds
.where((id) => !existingIds.contains(id))
.toList();
// Fetch conversations for those ids in parallel (cap to avoid overload)
const maxFetch = 50;
final fetchList = idsToFetch.take(maxFetch).toList();
if (fetchList.isNotEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-from-messages',
scope: 'search',
data: {'count': fetchList.length},
);
final fetched = await Future.wait(
fetchList.map((id) async {
try {
return await api.getConversation(id);
} catch (_) {
return null;
}
}),
);
// Merge fetched conversations
for (final conv in fetched) {
if (conv != null && !existingIds.contains(conv.id)) {
conversations.add(conv);
existingIds.add(conv.id);
}
}
// Optional: sort by updated date desc to keep results consistent
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('message-search-failed', scope: 'search', error: e);
}
2025-08-10 01:20:45 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'server-results',
scope: 'search',
data: {'count': conversations.length},
);
2025-08-10 01:20:45 +05:30
return conversations;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('server-search-failed', scope: 'search', error: e);
2025-08-10 01:20:45 +05:30
// Fallback to local search if server search fails
final allConversations = await ref.read(conversationsProvider.future);
2025-09-25 22:36:42 +05:30
DebugLogger.log('fallback-local', scope: 'search');
2025-08-10 01:20:45 +05:30
return allConversations.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) =>
msg.content.toLowerCase().contains(query.toLowerCase()),
));
}).toList();
}
});
final filteredConversationsProvider = Provider<List<Conversation>>((ref) {
final conversations = ref.watch(conversationsProvider);
final query = ref.watch(searchQueryProvider);
// Use server-side search when there's a query
if (query.trim().isNotEmpty) {
final searchResults = ref.watch(serverSearchProvider(query));
return searchResults.maybeWhen(
data: (results) => results,
loading: () {
// While server search is loading, show local filtered results
return conversations.maybeWhen(
data: (convs) => convs.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) => msg.content.toLowerCase().contains(
query.toLowerCase(),
),
));
}).toList(),
orElse: () => [],
);
},
error: (_, stackTrace) {
// On error, fallback to local search
return conversations.maybeWhen(
data: (convs) => convs.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) => msg.content.toLowerCase().contains(
query.toLowerCase(),
),
));
}).toList(),
orElse: () => [],
);
},
orElse: () => [],
);
}
// When no search query, show all non-archived conversations
return conversations.maybeWhen(
data: (convs) {
if (ref.watch(reviewerModeProvider)) {
return convs; // Already filtered above for demo
}
// Filter out archived conversations (they should be in a separate view)
final filtered = convs.where((conv) => !conv.archived).toList();
// Sort: pinned conversations first, then by updated date
filtered.sort((a, b) {
// Pinned conversations come first
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
// Within same pin status, sort by updated date (newest first)
return b.updatedAt.compareTo(a.updatedAt);
});
return filtered;
},
orElse: () => [],
);
});
// Provider for archived conversations
final archivedConversationsProvider = Provider<List<Conversation>>((ref) {
final conversations = ref.watch(conversationsProvider);
return conversations.maybeWhen(
data: (convs) {
if (ref.watch(reviewerModeProvider)) {
return convs.where((c) => c.archived).toList();
}
// Only show archived conversations
final archived = convs.where((conv) => conv.archived).toList();
// Sort by updated date (newest first)
archived.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return archived;
},
orElse: () => [],
);
});
// Reviewer mode provider (persisted)
2025-09-21 22:31:44 +05:30
final reviewerModeProvider = NotifierProvider<ReviewerModeNotifier, bool>(
ReviewerModeNotifier.new,
2025-08-10 01:20:45 +05:30
);
2025-09-21 22:31:44 +05:30
class ReviewerModeNotifier extends Notifier<bool> {
late final OptimizedStorageService _storage;
bool _initialized = false;
@override
bool build() {
_storage = ref.watch(optimizedStorageServiceProvider);
if (!_initialized) {
_initialized = true;
Future.microtask(_load);
}
return false;
2025-08-10 01:20:45 +05:30
}
2025-09-21 22:31:44 +05:30
2025-08-10 01:20:45 +05:30
Future<void> _load() async {
final enabled = await _storage.getReviewerMode();
2025-09-21 22:31:44 +05:30
if (!ref.mounted) {
return;
}
2025-08-10 01:20:45 +05:30
state = enabled;
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
await _storage.setReviewerMode(enabled);
}
Future<void> toggle() => setEnabled(!state);
}
// User Settings providers
final userSettingsProvider = FutureProvider<UserSettings>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
// Return default settings if no API
return const UserSettings();
}
try {
final settingsData = await api.getUserSettings();
return UserSettings.fromJson(settingsData);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('user-settings-failed', scope: 'settings', error: e);
2025-08-10 01:20:45 +05:30
// Return default settings on error
return const UserSettings();
}
});
// Conversation Suggestions provider
final conversationSuggestionsProvider = FutureProvider<List<String>>((
ref,
) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getSuggestions();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('suggestions-failed', scope: 'suggestions', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});
2025-08-21 14:37:49 +05:30
// Server features and permissions
final userPermissionsProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return {};
try {
return await api.getUserPermissions();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('permissions-failed', scope: 'permissions', error: e);
2025-08-21 14:37:49 +05:30
return {};
}
});
final imageGenerationAvailableProvider = Provider<bool>((ref) {
final perms = ref.watch(userPermissionsProvider);
return perms.maybeWhen(
data: (data) {
final features = data['features'];
if (features is Map<String, dynamic>) {
final value = features['image_generation'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
}
return false;
},
orElse: () => false,
);
});
final webSearchAvailableProvider = Provider<bool>((ref) {
final perms = ref.watch(userPermissionsProvider);
return perms.maybeWhen(
data: (data) {
final features = data['features'];
if (features is Map<String, dynamic>) {
final value = features['web_search'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
}
return false;
},
orElse: () => false,
);
});
2025-08-10 01:20:45 +05:30
// Folders provider
final foldersProvider = FutureProvider<List<Folder>>((ref) async {
final api = ref.watch(apiServiceProvider);
2025-08-17 00:05:30 +05:30
if (api == null) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('api-missing', scope: 'folders');
2025-08-17 00:05:30 +05:30
return [];
}
2025-08-10 01:20:45 +05:30
try {
final foldersData = await api.getFolders();
2025-08-17 00:05:30 +05:30
final folders = foldersData
2025-08-10 01:20:45 +05:30
.map((folderData) => Folder.fromJson(folderData))
.toList();
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-ok',
scope: 'folders',
data: {'count': folders.length},
);
2025-08-17 00:05:30 +05:30
return folders;
2025-08-10 01:20:45 +05:30
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('fetch-failed', scope: 'folders', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});
// Files provider
final userFilesProvider = FutureProvider<List<FileInfo>>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final filesData = await api.getUserFiles();
return filesData.map((fileData) => FileInfo.fromJson(fileData)).toList();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('files-failed', scope: 'files', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});
// File content provider
final fileContentProvider = FutureProvider.family<String, String>((
ref,
fileId,
) async {
final api = ref.watch(apiServiceProvider);
if (api == null) throw Exception('No API service available');
try {
return await api.getFileContent(fileId);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'file-content-failed',
scope: 'files',
error: e,
data: {'fileId': fileId},
);
2025-08-10 01:20:45 +05:30
throw Exception('Failed to load file content: $e');
}
});
// Knowledge Base providers
final knowledgeBasesProvider = FutureProvider<List<KnowledgeBase>>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final kbData = await api.getKnowledgeBases();
return kbData.map((data) => KnowledgeBase.fromJson(data)).toList();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('knowledge-bases-failed', scope: 'knowledge', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});
final knowledgeBaseItemsProvider =
FutureProvider.family<List<KnowledgeBaseItem>, String>((ref, kbId) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final itemsData = await api.getKnowledgeBaseItems(kbId);
return itemsData
.map((data) => KnowledgeBaseItem.fromJson(data))
.toList();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'knowledge-items-failed',
scope: 'knowledge',
error: e,
);
2025-08-10 01:20:45 +05:30
return [];
}
});
// Audio providers
final availableVoicesProvider = FutureProvider<List<String>>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getAvailableVoices();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('voices-failed', scope: 'voices', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});
// Image Generation providers
final imageModelsProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getImageModels();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('image-models-failed', scope: 'image-models', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
});