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

2612 lines
78 KiB
Dart
Raw Normal View History

2025-09-28 23:18:24 +05:30
import 'dart:async';
import 'package:flutter/foundation.dart';
2025-08-10 01:20:45 +05:30
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
2025-09-28 23:18:24 +05:30
import 'package:riverpod_annotation/riverpod_annotation.dart';
2025-08-10 01:20:45 +05:30
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';
import '../models/backend_config.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/tool.dart';
2025-08-10 01:20:45 +05:30
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';
import '../services/connectivity_service.dart';
2025-08-20 22:15:26 +05:30
import '../utils/debug_logger.dart';
2025-09-29 00:22:12 +05:30
import '../models/socket_event.dart';
import '../services/worker_manager.dart';
import '../../shared/theme/tweakcn_themes.dart';
import '../../shared/theme/app_theme.dart';
import '../../features/tools/providers/tools_providers.dart';
import '../models/socket_transport_availability.dart';
import 'storage_providers.dart';
2025-08-10 01:20:45 +05:30
export 'storage_providers.dart';
2025-09-28 23:18:24 +05:30
part 'app_providers.g.dart';
2025-08-10 01:20:45 +05:30
// Theme provider
@Riverpod(keepAlive: true)
class AppThemeMode extends _$AppThemeMode {
2025-09-21 22:31:44 +05:30
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());
}
}
@Riverpod(keepAlive: true)
class AppThemePalette extends _$AppThemePalette {
late final OptimizedStorageService _storage;
@override
TweakcnThemeDefinition build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final storedId = _storage.getThemePaletteId();
return TweakcnThemes.byId(storedId);
}
Future<void> setPalette(String paletteId) async {
final palette = TweakcnThemes.byId(paletteId);
state = palette;
await _storage.setThemePaletteId(palette.id);
}
}
@Riverpod(keepAlive: true)
class AppLightTheme extends _$AppLightTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.light(palette);
}
}
@Riverpod(keepAlive: true)
class AppDarkTheme extends _$AppDarkTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.dark(palette);
}
}
// Locale provider
@Riverpod(keepAlive: true)
class AppLocale extends _$AppLocale {
2025-09-21 22:31:44 +05:30
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) {
final parsed = _parseLocaleCode(code);
if (parsed != null) return parsed;
}
2025-09-21 22:31:44 +05:30
return null; // system default
}
Future<void> setLocale(Locale? locale) async {
state = locale;
await _storage.setLocaleCode(locale?.toLanguageTag());
}
Locale? _parseLocaleCode(String code) {
final normalized = code.replaceAll('_', '-');
final parts = normalized.split('-');
if (parts.isEmpty || parts.first.isEmpty) return null;
final language = parts.first;
String? script;
String? country;
for (var i = 1; i < parts.length; i++) {
final part = parts[i];
if (part.length == 4) {
script = '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}';
} else if (part.length == 2 || part.length == 3) {
country = part.toUpperCase();
}
}
return Locale.fromSubtags(
languageCode: language,
scriptCode: script,
countryCode: country,
);
}
}
2025-08-10 01:20:45 +05:30
// Server connection providers - optimized with caching
@Riverpod(keepAlive: true)
Future<List<ServerConfig>> serverConfigs(Ref ref) async {
2025-08-10 01:20:45 +05:30
final storage = ref.watch(optimizedStorageServiceProvider);
return storage.getServerConfigs();
}
2025-08-10 01:20:45 +05:30
@Riverpod(keepAlive: true)
Future<ServerConfig?> activeServer(Ref ref) async {
2025-08-10 01:20:45 +05:30
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,
);
});
@Riverpod(keepAlive: true)
class BackendConfigNotifier extends _$BackendConfigNotifier {
late final OptimizedStorageService _storage;
@override
Future<BackendConfig?> build() async {
_storage = ref.watch(optimizedStorageServiceProvider);
final cached = await _storage.getLocalBackendConfig();
unawaited(_refreshBackendConfig());
return cached;
}
Future<void> refresh() => _refreshBackendConfig();
Future<void> _refreshBackendConfig() async {
final fresh = await _loadBackendConfig(ref);
if (fresh == null || !ref.mounted) {
return;
}
state = AsyncData(fresh);
await _storage.saveLocalBackendConfig(fresh);
// Persist resolved transport options based on backend config
if (!ref.mounted) return;
final options = _resolveTransportAvailability(fresh);
await _storage.saveLocalTransportOptions(options);
}
}
Future<BackendConfig?> _loadBackendConfig(Ref ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
return null;
}
final server = await ref.watch(activeServerProvider.future);
if (server == null) {
return null;
}
try {
final config = await api.getBackendConfig();
if (config != null) {
final forcedMode = config.enforcedTransportMode;
if (forcedMode != null) {
final settings = ref.read(appSettingsProvider);
if (settings.socketTransportMode != forcedMode) {
Future.microtask(() {
ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode(forcedMode);
});
}
}
}
return config;
} catch (_) {
return null;
}
}
/// Provides resolved socket transport options based on backend configuration.
///
/// This is a synchronous provider that:
/// - Returns cached transport options when backend config is not yet loaded
/// - Derives transport options from backend config once available
/// - Does NOT perform side effects (persistence is handled by BackendConfigNotifier)
///
/// The persistence of resolved options happens asynchronously when the
/// backend config is refreshed, ensuring the sync provider remains pure.
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
ref,
) {
final storage = ref.watch(optimizedStorageServiceProvider);
// Watch async backend config for proper invalidation
final backendConfigAsync = ref.watch(backendConfigProvider);
final config = backendConfigAsync.maybeWhen(
data: (value) => value,
orElse: () => null,
);
if (config == null) {
// Return cached value or defaults when config not available
return storage.getLocalTransportOptionsSync() ??
const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}
// Determine transport availability from backend config
return _resolveTransportAvailability(config);
});
2025-08-10 01:20:45 +05:30
// 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);
final workerManager = ref.watch(workerManagerProvider);
2025-08-10 01:20:45 +05:30
return activeServer.maybeWhen(
data: (server) {
if (server == null) return null;
final apiService = ApiService(
serverConfig: server,
workerManager: workerManager,
2025-08-10 01:20:45 +05:30
authToken: null, // Will be set by auth state manager
);
// Keep callbacks in sync so interceptor can notify auth manager
apiService.setAuthCallbacks(
onAuthTokenInvalid: () {
// Called when auth errors occur (401/403)
// Show connection issue page instead of logging out
final authManager = ref.read(authStateManagerProvider.notifier);
authManager.onAuthIssue();
},
2025-08-10 01:20:45 +05:30
onTokenInvalidated: () async {
// Called for token expiry - attempt silent re-login
2025-08-10 01:20:45 +05:30
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 = () {
// Show connection issue page instead of logging out
final authManager = ref.read(authStateManagerProvider.notifier);
authManager.onAuthIssue();
2025-08-10 01:20:45 +05:30
};
return apiService;
},
orElse: () => null,
);
});
2025-08-31 14:02:44 +05:30
// Socket.IO service provider
2025-09-28 23:18:24 +05:30
@Riverpod(keepAlive: true)
class SocketServiceManager extends _$SocketServiceManager {
SocketService? _service;
ProviderSubscription<String?>? _tokenSubscription;
ProviderSubscription<ConnectivityStatus>? _connectivitySubscription;
int _connectToken = 0;
2025-08-31 14:02:44 +05:30
2025-09-28 23:18:24 +05:30
@override
FutureOr<SocketService?> build() async {
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
_disposeService();
return null;
}
2025-08-31 14:02:44 +05:30
2025-09-28 23:18:24 +05:30
final server = await ref.watch(activeServerProvider.future);
if (server == null) {
_disposeService();
return null;
}
final transportMode = ref.watch(
appSettingsProvider.select((settings) => settings.socketTransportMode),
);
final websocketOnly = transportMode == 'ws';
final transportAvailability = ref.watch(socketTransportOptionsProvider);
final allowWebsocketUpgrade = transportAvailability.allowWebsocketOnly;
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
// Token updates are handled via the subscription below
final token = ref.read(authTokenProvider3);
2025-09-28 23:18:24 +05:30
final requiresNewService =
_service == null ||
_service!.serverConfig.id != server.id ||
_service!.websocketOnly != websocketOnly ||
_service!.allowWebsocketUpgrade != allowWebsocketUpgrade;
2025-09-28 23:18:24 +05:30
if (requiresNewService) {
_disposeService();
_service = SocketService(
serverConfig: server,
authToken: token,
2025-09-28 23:18:24 +05:30
websocketOnly: websocketOnly,
allowWebsocketUpgrade: allowWebsocketUpgrade,
);
2025-09-28 23:18:24 +05:30
_scheduleConnect(_service!);
} else {
_service!.updateAuthToken(token);
}
_tokenSubscription ??= ref.listen<String?>(authTokenProvider3, (
previous,
next,
) {
_service?.updateAuthToken(next);
});
// Listen to connectivity changes to proactively manage socket connection.
// When network goes offline, we can save resources by not attempting
// reconnections. When network comes back, we force a reconnect.
_connectivitySubscription ??= ref.listen<ConnectivityStatus>(
connectivityStatusProvider,
(previous, next) {
final service = _service;
if (service == null) return;
if (next == ConnectivityStatus.offline) {
// Network is offline - socket will handle its own disconnection
// via the underlying transport. We just log it for debugging.
DebugLogger.log(
'Connectivity offline - socket may disconnect',
scope: 'socket/provider',
);
} else if (previous == ConnectivityStatus.offline &&
next == ConnectivityStatus.online) {
// Network just came back online - force reconnect to restore socket
DebugLogger.log(
'Connectivity restored - forcing socket reconnect',
scope: 'socket/provider',
);
unawaited(service.connect(force: true));
}
},
);
2025-09-28 23:18:24 +05:30
ref.onDispose(() {
_tokenSubscription?.close();
_tokenSubscription = null;
_connectivitySubscription?.close();
_connectivitySubscription = null;
2025-09-28 23:18:24 +05:30
_disposeService();
});
return _service;
}
void _scheduleConnect(SocketService service) {
final token = ++_connectToken;
2025-09-28 23:18:24 +05:30
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!ref.mounted) return;
if (_connectToken != token) return;
if (!identical(_service, service)) return;
2025-09-28 23:18:24 +05:30
try {
unawaited(service.connect());
} catch (_) {}
});
}
void _disposeService() {
_connectToken++;
2025-09-28 23:18:24 +05:30
if (_service == null) return;
try {
_service!.dispose();
} catch (_) {}
_service = null;
}
}
final socketServiceProvider = Provider<SocketService?>((ref) {
final asyncService = ref.watch(socketServiceManagerProvider);
return asyncService.maybeWhen(data: (service) => service, orElse: () => null);
2025-08-31 14:02:44 +05:30
});
2025-09-29 00:22:12 +05:30
@Riverpod(keepAlive: true)
class ConversationDeltaStream extends _$ConversationDeltaStream {
StreamController<ConversationDelta>? _controller;
ProviderSubscription<AsyncValue<SocketService?>>? _serviceSubscription;
SocketEventSubscription? _socketSubscription;
@override
Stream<ConversationDelta> build(ConversationDeltaRequest request) {
final controller = StreamController<ConversationDelta>.broadcast(
sync: true,
onCancel: _maybeTearDownSocket,
);
_controller = controller;
final initialService = ref
.watch(socketServiceManagerProvider)
.maybeWhen(data: (service) => service, orElse: () => null);
_bindSocket(initialService, request);
_serviceSubscription = ref.listen<AsyncValue<SocketService?>>(
socketServiceManagerProvider,
(_, next) => _bindSocket(
next.maybeWhen(data: (service) => service, orElse: () => null),
request,
),
);
ref.onDispose(() {
_serviceSubscription?.close();
_serviceSubscription = null;
_socketSubscription?.dispose();
_socketSubscription = null;
_controller?.close();
_controller = null;
});
2025-09-29 00:22:12 +05:30
return controller.stream;
}
2025-09-29 00:22:12 +05:30
void _bindSocket(SocketService? service, ConversationDeltaRequest request) {
_socketSubscription?.dispose();
_socketSubscription = null;
2025-09-29 00:22:12 +05:30
if (service == null) {
return;
}
switch (request.source) {
case ConversationDeltaSource.chat:
_socketSubscription = service.addChatEventHandler(
2025-09-29 00:22:12 +05:30
conversationId: request.conversationId,
sessionId: request.sessionId,
requireFocus: request.requireFocus,
handler: (event, ack) {
_controller?.add(
ConversationDelta.fromSocketEvent(
ConversationDeltaSource.chat,
event,
ack,
),
);
2025-09-29 00:22:12 +05:30
},
);
break;
case ConversationDeltaSource.channel:
_socketSubscription = service.addChannelEventHandler(
2025-09-29 00:22:12 +05:30
conversationId: request.conversationId,
sessionId: request.sessionId,
requireFocus: request.requireFocus,
handler: (event, ack) {
_controller?.add(
ConversationDelta.fromSocketEvent(
ConversationDeltaSource.channel,
event,
ack,
),
);
2025-09-29 00:22:12 +05:30
},
);
break;
}
}
void _maybeTearDownSocket() {
if (_controller?.hasListener == true) {
return;
}
_socketSubscription?.dispose();
_socketSubscription = null;
}
2025-10-01 00:35:56 +05:30
}
2025-09-29 00:22:12 +05:30
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) {
void syncToken(ApiService? api, String? token) {
if (api == null) return;
api.updateAuthToken(token != null && token.isNotEmpty ? token : null);
final length = token?.length ?? 0;
DebugLogger.auth(
'token-updated',
scope: 'auth/api',
data: {'length': length},
);
}
syncToken(ref.read(apiServiceProvider), ref.read(authTokenProvider3));
ref.listen<ApiService?>(apiServiceProvider, (previous, next) {
syncToken(next, ref.read(authTokenProvider3));
});
2025-09-23 13:43:01 +05:30
ref.listen<String?>(authTokenProvider3, (previous, next) {
syncToken(ref.read(apiServiceProvider), next);
2025-08-10 01:20:45 +05:30
});
});
@Riverpod(keepAlive: true)
Future<User?> currentUser(Ref ref) async {
2025-08-10 01:20:45 +05:30
final api = ref.read(apiServiceProvider);
final authState = ref.watch(authStateManagerProvider);
final isAuthenticated = authState.maybeWhen(
data: (state) => state.isAuthenticated,
orElse: () => false,
);
2025-08-10 01:20:45 +05:30
if (api == null || !isAuthenticated) return null;
// Fast path: use user already in auth state.
final authUser = authState.maybeWhen(
data: (state) => state.user,
orElse: () => null,
);
if (authUser != null) return authUser;
// Next: try cached user from storage, then refresh in the background.
final storage = ref.read(optimizedStorageServiceProvider);
final cachedUser = await _getCachedUserWithAvatar(storage);
if (cachedUser != null) {
final lastRefresh = ref.read(_lastUserRefreshProvider);
final now = DateTime.now();
final shouldRefresh =
lastRefresh == null ||
now.difference(lastRefresh) > const Duration(minutes: 5);
if (shouldRefresh) {
Future.microtask(() async {
final fresh = await _refreshCurrentUser(ref);
if (fresh != null && ref.mounted) {
ref.read(_lastUserRefreshProvider.notifier).set(now);
ref.invalidate(currentUserProvider);
}
});
}
return cachedUser;
}
// Fallback: fetch fresh.
final fresh = await _refreshCurrentUser(ref);
if (fresh != null) {
ref.read(_lastUserRefreshProvider.notifier).set(DateTime.now());
}
return fresh;
}
Future<User?> _getCachedUserWithAvatar(OptimizedStorageService storage) async {
final cachedUser = await storage.getLocalUser();
if (cachedUser == null) return null;
final cachedAvatar = await storage.getLocalUserAvatar();
if (cachedAvatar == null ||
cachedAvatar.isEmpty ||
cachedUser.profileImage == cachedAvatar) {
return cachedUser;
}
return cachedUser.copyWith(profileImage: cachedAvatar);
}
Future<User?> _refreshCurrentUser(Ref ref) async {
final api = ref.read(apiServiceProvider);
if (api == null) return null;
2025-08-10 01:20:45 +05:30
try {
final user = await api.getCurrentUser();
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveLocalUser(user);
if (user.profileImage != null && user.profileImage!.isNotEmpty) {
await storage.saveLocalUserAvatar(user.profileImage);
}
return user;
} catch (_) {
2025-08-10 01:20:45 +05:30
return null;
}
}
2025-08-10 01:20:45 +05:30
@Riverpod(keepAlive: true)
class _LastUserRefresh extends _$LastUserRefresh {
@override
DateTime? build() => null;
void set(DateTime? timestamp) => state = timestamp;
}
2025-08-10 01:20:45 +05:30
// 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
@Riverpod(keepAlive: true)
class Models extends _$Models {
@override
Future<List<Model>> build() async {
// Reviewer mode returns mock models
if (ref.watch(reviewerModeProvider)) {
return _demoModels();
}
if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'models');
_persistModelsAsync(const <Model>[]);
return const [];
}
final storage = ref.watch(optimizedStorageServiceProvider);
try {
final cached = await storage.getLocalModels();
if (cached.isNotEmpty) {
DebugLogger.log(
'cache-restored',
scope: 'models/cache',
data: {'count': cached.length},
);
Future.microtask(() async {
try {
await refresh();
} catch (error, stackTrace) {
DebugLogger.error(
'warm-refresh-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
}
});
return cached;
}
} catch (error, stackTrace) {
DebugLogger.error(
'cache-load-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
}
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('api-missing', scope: 'models');
_persistModelsAsync(const <Model>[]);
return const [];
}
final fresh = await _load(api);
return fresh;
2025-08-10 01:20:45 +05:30
}
Future<void> refresh() async {
if (ref.read(reviewerModeProvider)) {
state = AsyncData<List<Model>>(_demoModels());
return;
}
if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<Model>>(<Model>[]);
_persistModelsAsync(const <Model>[]);
return;
}
final api = ref.read(apiServiceProvider);
if (api == null) {
state = const AsyncData<List<Model>>(<Model>[]);
_persistModelsAsync(const <Model>[]);
return;
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
// Update selected model with fresh data (e.g., filters) if it exists
// in the new models list
if (result.hasValue) {
final freshModels = result.value!;
final currentSelected = ref.read(selectedModelProvider);
if (currentSelected != null) {
try {
final freshModel = freshModels.firstWhere(
(m) => m.id == currentSelected.id,
);
// Update selected model with fresh data (filters, etc.)
if (freshModel != currentSelected) {
ref.read(selectedModelProvider.notifier).set(freshModel);
DebugLogger.log(
'selected-model-refreshed',
scope: 'models',
data: {
'id': freshModel.id,
'filters': freshModel.filters?.length ?? 0,
},
);
}
} catch (_) {
// Model no longer available - keep current selection
}
}
}
}
Future<List<Model>> _load(ApiService api) async {
try {
DebugLogger.log('fetch-start', scope: 'models');
final models = await api.getModels();
DebugLogger.log(
'fetch-ok',
scope: 'models',
data: {'count': models.length},
);
_persistModelsAsync(models);
return models;
} catch (e, stackTrace) {
DebugLogger.error(
'fetch-failed',
scope: 'models',
error: e,
stackTrace: stackTrace,
);
// 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')) {
DebugLogger.warning('endpoint-403', scope: 'models');
}
2025-08-10 01:20:45 +05:30
return const [];
2025-08-10 01:20:45 +05:30
}
}
2025-08-10 01:20:45 +05:30
void _persistModelsAsync(List<Model> models) {
final storage = ref.read(optimizedStorageServiceProvider);
unawaited(
storage.saveLocalModels(models).onError((error, stack) {
DebugLogger.error(
'Failed to persist models to cache',
scope: 'models/cache',
error: error,
stackTrace: stack,
);
}),
);
2025-08-10 01:20:45 +05:30
}
List<Model> _demoModels() => 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'],
),
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'],
),
];
}
2025-08-10 01:20:45 +05:30
@Riverpod(keepAlive: true)
class SelectedModel extends _$SelectedModel {
2025-09-21 22:31:44 +05:30
@override
Model? build() => null;
void set(Model? model) => state = model;
void clear() => state = null;
}
/// Tracks a pending folder ID for the next new conversation.
///
/// When a user starts a new chat from within a folder context menu,
/// this provider holds the folder ID so that the conversation is
/// automatically placed in that folder upon creation.
@Riverpod(keepAlive: true)
class PendingFolderId extends _$PendingFolderId {
@override
String? build() => null;
void set(String? folderId) => state = folderId;
void clear() => state = null;
}
// Track if the current model selection is manual (user-selected) or automatic (default)
@Riverpod(keepAlive: true)
class IsManualModelSelection extends _$IsManualModelSelection {
2025-09-21 22:31:44 +05:30
@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
// keepAlive to maintain listener throughout app lifecycle
2025-08-17 17:43:19 +05:30
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
}
});
});
// Auto-apply model-specific tools when model changes or tools load
final modelToolsAutoSelectionProvider = Provider<void>((ref) {
// Prevent disposal so listeners remain active throughout app lifecycle
ref.keepAlive();
Future<void> applyTools(Model? model) async {
// Skip if not authenticated - prevents API calls after logout
final authState = ref.read(authStateManagerProvider).asData?.value;
if (authState == null || !authState.isAuthenticated) {
final current = ref.read(selectedToolIdsProvider);
if (current.isNotEmpty) {
ref.read(selectedToolIdsProvider.notifier).set([]);
}
return;
}
if (model == null) {
final current = ref.read(selectedToolIdsProvider);
if (current.isNotEmpty) {
ref.read(selectedToolIdsProvider.notifier).set([]);
}
return;
}
final modelToolIds = model.toolIds ?? [];
if (modelToolIds.isEmpty) {
final current = ref.read(selectedToolIdsProvider);
if (current.isNotEmpty) {
ref.read(selectedToolIdsProvider.notifier).set([]);
}
return;
}
void updateSelection(List<Tool> availableTools) {
final validToolIds = modelToolIds
.where((id) => availableTools.any((tool) => tool.id == id))
.toList();
final currentSelection = ref.read(selectedToolIdsProvider);
if (validToolIds.isEmpty) {
if (currentSelection.isNotEmpty) {
ref.read(selectedToolIdsProvider.notifier).set([]);
}
return;
}
if (listEquals(currentSelection, validToolIds)) return;
ref.read(selectedToolIdsProvider.notifier).set(validToolIds);
DebugLogger.log(
'auto-apply-tools',
scope: 'models/tools',
data: {'modelId': model.id, 'toolCount': validToolIds.length},
);
}
final toolsAsync = ref.read(toolsListProvider);
if (toolsAsync.hasValue) {
updateSelection(toolsAsync.value ?? const <Tool>[]);
return;
}
try {
final availableTools = await ref.read(toolsListProvider.future);
if (!ref.mounted) return;
updateSelection(availableTools);
} catch (error, stackTrace) {
DebugLogger.error(
'auto-apply-tools-failed',
scope: 'models/tools',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> scheduleApply(Model? model) async {
await applyTools(model);
}
Future.microtask(() => scheduleApply(ref.read(selectedModelProvider)));
ref.listen<Model?>(selectedModelProvider, (previous, next) {
if (previous?.id == next?.id && previous != null) {
return;
}
Future.microtask(() => scheduleApply(next));
});
ref.listen(toolsListProvider, (previous, next) {
if (!next.hasValue) return;
Future.microtask(() => scheduleApply(ref.read(selectedModelProvider)));
});
});
// Auto-clear invalid filter selections when model changes
// Filters are model-specific, so we need to validate selections against new model
final modelFiltersAutoSelectionProvider = Provider<void>((ref) {
// Prevent disposal so listeners remain active throughout app lifecycle
ref.keepAlive();
void validateFilters(Model? model) {
final currentFilterIds = ref.read(selectedFilterIdsProvider);
if (currentFilterIds.isEmpty) return;
// Get available filters from the model
final availableFilters = model?.filters ?? const [];
final validFilterIds = availableFilters.map((f) => f.id).toSet();
// Filter out any selected IDs that aren't valid for this model
final validSelection = currentFilterIds
.where((id) => validFilterIds.contains(id))
.toList();
// Only update if something changed
if (validSelection.length != currentFilterIds.length) {
ref.read(selectedFilterIdsProvider.notifier).set(validSelection);
DebugLogger.log(
'filter-selection-validated',
scope: 'models/filters',
data: {
'modelId': model?.id,
'previousCount': currentFilterIds.length,
'validCount': validSelection.length,
},
);
}
}
// Validate on model change
ref.listen<Model?>(selectedModelProvider, (previous, next) {
if (previous?.id == next?.id && previous != null) {
return;
}
Future.microtask(() => validateFilters(next));
});
});
2025-08-28 19:17:05 +05:30
// Auto-apply default model from settings when it changes (and not manually overridden)
// keepAlive to maintain listener throughout app lifecycle
2025-08-28 19:17:05 +05:30
final defaultModelAutoSelectionProvider = Provider<void>((ref) {
// Prevent disposal so listeners remain active throughout app lifecycle
ref.keepAlive();
// Initialize the model tools and filters auto-selection
ref.watch(modelToolsAutoSelectionProvider);
ref.watch(modelFiltersAutoSelectionProvider);
2025-08-28 19:17:05 +05:30
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
@Riverpod(keepAlive: true)
class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp {
2025-09-21 22:31:44 +05:30
@override
DateTime? build() => null;
void set(DateTime? timestamp) => state = timestamp;
}
2025-08-17 16:11:19 +05:30
/// Clears the in-memory timestamp cache and triggers a refresh of the
/// conversations provider. Optionally refreshes the folders provider so folder
/// metadata stays in sync.
void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
final notifier = ref.read(conversationsProvider.notifier);
unawaited(notifier.refresh(includeFolders: includeFolders));
if (includeFolders) {
final foldersNotifier = ref.read(foldersProvider.notifier);
unawaited(foldersNotifier.refresh());
}
}
// Conversation providers - Now using correct OpenWebUI API with caching and
// immediate mutation helpers.
@Riverpod(keepAlive: true)
class Conversations extends _$Conversations {
@override
Future<List<Conversation>> build() async {
final authed = ref.watch(isAuthenticatedProvider2);
if (!authed) {
DebugLogger.log('skip-unauthed', scope: 'conversations');
_updateCacheTimestamp(null);
_persistConversationsAsync(const <Conversation>[]);
return const [];
}
if (ref.watch(reviewerModeProvider)) {
return _demoConversations();
}
final storage = ref.read(optimizedStorageServiceProvider);
try {
final cached = await storage.getLocalConversations();
if (cached.isNotEmpty) {
final sortedCached = _sortByUpdatedAt(cached);
Future.microtask(() async {
try {
await refresh(includeFolders: true);
} catch (error, stackTrace) {
DebugLogger.error(
'warm-refresh-failed',
scope: 'conversations/cache',
error: error,
stackTrace: stackTrace,
);
}
});
return sortedCached;
}
} catch (error, stackTrace) {
DebugLogger.error(
'cache-load-failed',
scope: 'conversations/cache',
error: error,
stackTrace: stackTrace,
);
}
final fresh = await _loadRemoteConversations();
_persistConversationsAsync(fresh);
return fresh;
2025-08-17 16:11:19 +05:30
}
Future<void> refresh({bool includeFolders = false}) async {
final authed = ref.read(isAuthenticatedProvider2);
if (!authed) {
_updateCacheTimestamp(null);
state = AsyncData<List<Conversation>>(<Conversation>[]);
_persistConversationsAsync(const <Conversation>[]);
if (includeFolders) {
unawaited(ref.read(foldersProvider.notifier).refresh());
}
return;
}
if (ref.read(reviewerModeProvider)) {
state = AsyncData<List<Conversation>>(_demoConversations());
if (includeFolders) {
unawaited(ref.read(foldersProvider.notifier).refresh());
}
return;
}
final result = await AsyncValue.guard(_loadRemoteConversations);
if (!ref.mounted) return;
result.when(
data: (conversations) {
state = AsyncData<List<Conversation>>(conversations);
_persistConversationsAsync(conversations);
},
error: (error, stackTrace) {
DebugLogger.error(
'refresh-failed',
scope: 'conversations',
error: error,
stackTrace: stackTrace,
data: {'preservedData': state.asData != null},
);
},
loading: () {},
);
if (includeFolders) {
unawaited(ref.read(foldersProvider.notifier).refresh());
}
2025-08-10 01:20:45 +05:30
}
void removeConversation(String id) {
final current = state.asData?.value;
if (current == null) return;
final updated = current
.where((conversation) => conversation.id != id)
.toList(growable: true);
_replaceState(updated);
2025-08-10 01:20:45 +05:30
}
void upsertConversation(Conversation conversation) {
final current = state.asData?.value ?? const <Conversation>[];
final updated = <Conversation>[...current];
final index = updated.indexWhere(
(element) => element.id == conversation.id,
2025-08-10 01:20:45 +05:30
);
if (index >= 0) {
updated[index] = conversation;
} else {
updated.add(conversation);
}
_replaceState(updated);
}
void updateConversation(
String id,
Conversation Function(Conversation conversation) transform,
) {
final current = state.asData?.value;
if (current == null) return;
final index = current.indexWhere((conversation) => conversation.id == id);
if (index < 0) return;
final updated = <Conversation>[...current];
updated[index] = transform(updated[index]);
_replaceState(updated);
}
void _replaceState(List<Conversation> conversations) {
final sorted = _sortByUpdatedAt(conversations);
state = AsyncData<List<Conversation>>(sorted);
_persistConversationsAsync(sorted);
}
void _persistConversationsAsync(List<Conversation> conversations) {
final storage = ref.read(optimizedStorageServiceProvider);
unawaited(
Future<void>(() async {
try {
await storage.saveLocalConversations(conversations);
} catch (error, stackTrace) {
DebugLogger.error(
'cache-save-failed',
scope: 'conversations/cache',
error: error,
stackTrace: stackTrace,
);
}
}),
);
}
List<Conversation> _demoConversations() => [
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)),
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.',
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
model: 'Gemma 2 Mini (Demo)',
isStreaming: false,
),
],
),
];
Future<List<Conversation>> _loadRemoteConversations() async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('api-missing', scope: 'conversations');
return const [];
}
try {
DebugLogger.log('fetch-start', scope: 'conversations');
final conversationsFuture = api.getConversations();
final foldersFuture = api.getFolders().catchError((error, stackTrace) {
DebugLogger.error(
'folders-fetch-failed',
scope: 'conversations',
error: error,
stackTrace: stackTrace,
);
// Preserve the existing enabled state on error (don't override a
// previously determined disabled state due to network errors)
final currentEnabled = ref.read(foldersFeatureEnabledProvider);
return (const <Map<String, dynamic>>[], currentEnabled);
});
final results = await Future.wait<dynamic>([
conversationsFuture,
foldersFuture,
]);
final conversations = results[0] as List<Conversation>;
final foldersResult = results[1] as (List<Map<String, dynamic>>, bool);
final foldersData = foldersResult.$1;
final foldersEnabled = foldersResult.$2;
// Update the folders feature enabled state
ref
.read(foldersFeatureEnabledProvider.notifier)
.setEnabled(foldersEnabled);
2025-08-20 22:15:26 +05:30
DebugLogger.log(
'fetch-ok',
2025-09-25 22:36:42 +05:30
scope: 'conversations',
data: {'count': conversations.length},
);
DebugLogger.log(
'folders-fetched',
scope: 'conversations',
data: {'count': foldersData.length},
);
final folders = foldersData
.map((folderData) => Folder.fromJson(folderData))
.toList();
final conversationToFolder = <String, String>{};
for (final folder in folders) {
for (final conversationId in folder.conversationIds) {
conversationToFolder[conversationId] = folder.id;
}
}
final conversationMap = <String, Conversation>{};
for (final conversation in conversations) {
final explicitFolderId = conversation.folderId;
final mappedFolderId = conversationToFolder[conversation.id];
final folderIdToUse = explicitFolderId ?? mappedFolderId;
if (folderIdToUse != null) {
conversationMap[conversation.id] = conversation.copyWith(
folderId: folderIdToUse,
);
} else {
conversationMap[conversation.id] = conversation;
}
}
2025-08-17 16:11:19 +05:30
final existingIds = conversationMap.keys.toSet();
final missingInBase = conversationToFolder.keys
.where((id) => !existingIds.contains(id))
.toList();
if (missingInBase.isNotEmpty) {
DebugLogger.warning(
'missing-in-base',
scope: 'conversations/map',
data: {
'count': missingInBase.length,
'preview': missingInBase.take(5).toList(),
},
);
}
for (final folder in folders) {
final missingIds = folder.conversationIds
.where((id) => !existingIds.contains(id))
.toList();
final hasKnownConversations = conversationMap.values.any(
(conversation) => conversation.folderId == folder.id,
);
final shouldFetchFolder =
missingIds.isNotEmpty ||
(!hasKnownConversations && folder.conversationIds.isEmpty);
List<Conversation> folderConvs = const [];
if (shouldFetchFolder) {
try {
folderConvs = await api.getFolderConversationSummaries(folder.id);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'folder-sync',
2025-09-25 22:36:42 +05:30
scope: 'conversations/map',
data: {
'folderId': folder.id,
'fetched': folderConvs.length,
'missingIds': missingIds.length,
},
);
} catch (e) {
DebugLogger.error(
'folder-fetch-failed',
scope: 'conversations/map',
error: e,
data: {'folderId': folder.id},
);
2025-08-17 00:05:30 +05:30
}
}
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;
conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id);
} else {
final placeholder = Conversation(
id: convId,
title: 'Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
messages: const [],
folderId: folder.id,
);
conversationMap[convId] = placeholder;
existingIds.add(convId);
}
}
if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) {
for (final conv in folderConvs) {
final toAdd = conv.folderId == null
? conv.copyWith(folderId: folder.id)
: conv;
conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id);
}
}
}
final sortedConversations = _sortByUpdatedAt(
conversationMap.values.toList(),
);
_updateCacheTimestamp(DateTime.now());
return sortedConversations;
} catch (e, stackTrace) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'fetch-failed',
2025-09-25 22:36:42 +05:30
scope: 'conversations',
error: e,
stackTrace: stackTrace,
2025-09-25 22:36:42 +05:30
);
if (e.toString().contains('403')) {
DebugLogger.warning('endpoint-403', scope: 'conversations');
}
return const [];
2025-08-17 00:05:30 +05:30
}
}
2025-08-10 01:20:45 +05:30
List<Conversation> _sortByUpdatedAt(List<Conversation> conversations) {
final sorted = [...conversations];
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return List<Conversation>.unmodifiable(sorted);
}
2025-08-10 01:20:45 +05:30
void _updateCacheTimestamp(DateTime? timestamp) {
ref.read(_conversationsCacheTimestampProvider.notifier).set(timestamp);
2025-08-10 01:20:45 +05:30
}
}
2025-08-10 01:20:45 +05:30
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
@riverpod
Future<Conversation> loadConversation(Ref ref, String conversationId) async {
2025-08-10 01:20:45 +05:30
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;
}
2025-08-10 01:20:45 +05:30
// Provider to automatically load and set the default model from user settings or OpenWebUI
@Riverpod(keepAlive: true)
Future<Model?> defaultModel(Ref ref) async {
DebugLogger.log('provider-called', scope: 'models/default');
2025-08-28 18:54:06 +05:30
// Initialize the settings watcher (side-effect only)
ref.read(_settingsWatcherProvider);
final storage = ref.read(optimizedStorageServiceProvider);
2025-08-28 18:54:06 +05:30
// Read settings without subscribing to rebuilds to avoid watch/await hazards
final reviewerMode = ref.read(reviewerModeProvider);
if (reviewerMode) {
DebugLogger.log('reviewer-mode', scope: 'models/default');
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;
}
DebugLogger.warning('no-demo-models', scope: 'models/default');
return null;
}
2025-09-27 17:29:15 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('no-api', scope: 'models/default');
return null;
}
DebugLogger.log('api-available', scope: 'models/default');
2025-08-10 01:20:45 +05:30
try {
// Warm restore from cached resolved default model
try {
final cached = await storage.getLocalDefaultModel();
if (cached != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(cached);
DebugLogger.log(
'cached-default',
scope: 'models/default',
data: {'name': cached.name},
);
return cached;
}
} catch (_) {}
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 cachedMatch = await selectCachedModel(storage, storedDefaultId);
if (cachedMatch != null) {
ref.read(selectedModelProvider.notifier).set(cachedMatch);
unawaited(
storage.saveLocalDefaultModel(cachedMatch).onError((
error,
stack,
) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
DebugLogger.log(
'cache-select',
scope: 'models/default',
data: {'name': cachedMatch.name, 'source': 'cache'},
);
} else {
DebugLogger.log(
'cache-skip-missing',
scope: 'models/default',
data: {'id': storedDefaultId},
);
}
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 {
if (!ref.mounted) return;
List<Model> models;
final modelsAsync = ref.read(modelsProvider);
if (modelsAsync.hasValue) {
models = modelsAsync.value ?? const <Model>[];
} else {
models = await ref.read(modelsProvider.future);
}
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
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 (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
unawaited(
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
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
);
}
} catch (e) {
DebugLogger.error(
'reconcile-failed',
scope: 'models/default',
error: e,
);
}
2025-09-16 20:10:53 +05:30
});
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);
unawaited(
storage.saveLocalDefaultModel(placeholder).onError((error, stack) {
DebugLogger.error(
'Failed to save placeholder model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
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 {
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
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 (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
unawaited(
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
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 (e) {
DebugLogger.error(
'reconcile-failed',
scope: 'models/default',
error: e,
);
}
2025-09-16 20:10:53 +05:30
});
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
DebugLogger.log('fallback-path', scope: 'models/default');
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
DebugLogger.log(
'models-loaded',
scope: 'models/default',
data: {'count': models.length},
);
2025-09-16 20:10:53 +05:30
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);
unawaited(
storage.saveLocalDefaultModel(selectedModel).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fallback-selected',
2025-09-25 22:36:42 +05:30
scope: 'models/default',
data: {'name': selectedModel.name, 'id': selectedModel.id},
);
} else {
DebugLogger.log('skip-manual-override', scope: 'models/default');
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;
}
}
2025-08-10 01:20:45 +05:30
// 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);
// Watch auth state to trigger model loading when authenticated
final navState = ref.watch(authNavigationStateProvider);
2025-09-28 20:59:19 +05:30
if (navState != AuthNavigationState.authenticated) {
DebugLogger.log('skip-not-authed', scope: 'models/background');
2025-09-28 20:59:19 +05:30
return;
}
2025-08-10 01:20:45 +05:30
// Use a flag to prevent multiple concurrent loads
var isLoading = false;
2025-09-28 20:59:19 +05:30
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isLoading) return;
isLoading = true;
2025-09-28 20:59:19 +05:30
// Schedule background loading without blocking startup frame
Future.microtask(() async {
// Reduced delay for faster startup model selection
await Future.delayed(const Duration(milliseconds: 100));
if (!ref.mounted) {
DebugLogger.log('cancelled-unmounted', scope: 'models/background');
return;
}
2025-08-10 01:20:45 +05:30
2025-09-28 20:59:19 +05:30
DebugLogger.log('bg-start', scope: 'models/background');
try {
final model = await ref.read(defaultModelProvider.future);
if (!ref.mounted) {
DebugLogger.log('complete-unmounted', scope: 'models/background');
return;
}
DebugLogger.log(
'bg-complete',
scope: 'models/background',
data: {'model': model?.name ?? 'null'},
);
2025-09-28 20:59:19 +05:30
} catch (e) {
DebugLogger.error('bg-failed', scope: 'models/background', error: e);
} finally {
isLoading = false;
2025-09-28 20:59:19 +05:30
}
});
2025-08-10 01:20:45 +05:30
});
return;
});
// Search query provider
@Riverpod(keepAlive: true)
class SearchQuery extends _$SearchQuery {
2025-09-21 22:31:44 +05:30
@override
String build() => '';
void set(String query) => state = query;
}
2025-08-10 01:20:45 +05:30
// Server-side search provider for chats
@riverpod
Future<List<Conversation>> serverSearch(Ref ref, String query) async {
2025-08-10 01:20:45 +05:30
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();
}
}
2025-08-10 01:20:45 +05:30
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)
@Riverpod(keepAlive: true)
class ReviewerMode extends _$ReviewerMode {
2025-09-21 22:31:44 +05:30
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
@Riverpod(keepAlive: true)
Future<UserSettings> userSettings(Ref ref) async {
2025-08-10 01:20:45 +05:30
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();
}
}
2025-08-10 01:20:45 +05:30
// Conversation Suggestions provider
@Riverpod(keepAlive: true)
Future<List<String>> conversationSuggestions(Ref ref) async {
2025-08-10 01:20:45 +05:30
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-10 01:20:45 +05:30
2025-08-21 14:37:49 +05:30
// Server features and permissions
@Riverpod(keepAlive: true)
Future<Map<String, dynamic>> userPermissions(Ref ref) async {
2025-08-21 14:37:49 +05:30
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 {};
}
}
2025-08-21 14:37:49 +05:30
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,
);
});
/// Tracks whether the folders feature is enabled on the server.
/// When the server returns 403 for folders endpoint, this becomes false.
final foldersFeatureEnabledProvider =
NotifierProvider<FoldersFeatureEnabledNotifier, bool>(
FoldersFeatureEnabledNotifier.new,
);
class FoldersFeatureEnabledNotifier extends Notifier<bool> {
@override
bool build() => true;
void setEnabled(bool enabled) {
state = enabled;
}
}
/// Tracks whether the notes feature is enabled on the server.
/// When the server returns 403 for notes endpoint, this becomes false.
final notesFeatureEnabledProvider =
NotifierProvider<NotesFeatureEnabledNotifier, bool>(
NotesFeatureEnabledNotifier.new,
);
class NotesFeatureEnabledNotifier extends Notifier<bool> {
@override
bool build() => true;
void setEnabled(bool enabled) {
state = enabled;
}
}
2025-08-10 01:20:45 +05:30
// Folders provider
@Riverpod(keepAlive: true)
class Folders extends _$Folders {
@override
Future<List<Folder>> build() async {
if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'folders');
_persistFoldersAsync(const []);
return const [];
}
final storage = ref.watch(optimizedStorageServiceProvider);
final cached = await storage.getLocalFolders();
if (cached.isNotEmpty) {
Future.microtask(() async {
try {
await refresh();
} catch (error, stackTrace) {
DebugLogger.error(
'warm-refresh-failed',
scope: 'folders/cache',
error: error,
stackTrace: stackTrace,
);
}
});
return _sort(cached);
}
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('api-missing', scope: 'folders');
return const [];
}
final fresh = await _load(api);
return fresh;
2025-09-28 20:41:35 +05:30
}
Future<void> refresh() async {
if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<Folder>>([]);
_persistFoldersAsync(const []);
return;
}
final api = ref.read(apiServiceProvider);
if (api == null) {
state = const AsyncData<List<Folder>>([]);
_persistFoldersAsync(const []);
return;
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
2025-08-17 00:05:30 +05:30
}
2025-08-10 01:20:45 +05:30
void upsertFolder(Folder folder) {
final current = state.asData?.value ?? const <Folder>[];
final updated = <Folder>[...current];
final index = updated.indexWhere((existing) => existing.id == folder.id);
if (index >= 0) {
updated[index] = folder;
} else {
updated.add(folder);
}
final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
}
void updateFolder(String id, Folder Function(Folder folder) transform) {
final current = state.asData?.value;
if (current == null) return;
final index = current.indexWhere((folder) => folder.id == id);
if (index < 0) return;
final updated = <Folder>[...current];
updated[index] = transform(updated[index]);
final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
}
void removeFolder(String id) {
final current = state.asData?.value;
if (current == null) return;
final updated = current
.where((folder) => folder.id != id)
.toList(growable: true);
final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
}
Future<List<Folder>> _load(ApiService api) async {
try {
final (foldersData, featureEnabled) = await api.getFolders();
// Update the folders feature enabled state
ref
.read(foldersFeatureEnabledProvider.notifier)
.setEnabled(featureEnabled);
final folders = foldersData
.map((folderData) => Folder.fromJson(folderData))
.toList();
DebugLogger.log(
'fetch-ok',
scope: 'folders',
data: {'count': folders.length, 'enabled': featureEnabled},
);
final sorted = _sort(folders);
_persistFoldersAsync(sorted);
return sorted;
} catch (e, stackTrace) {
DebugLogger.error(
'fetch-failed',
scope: 'folders',
error: e,
stackTrace: stackTrace,
);
return const [];
}
}
void _persistFoldersAsync(List<Folder> folders) {
final storage = ref.read(optimizedStorageServiceProvider);
unawaited(storage.saveLocalFolders(folders));
}
List<Folder> _sort(List<Folder> input) {
final sorted = [...input];
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return List<Folder>.unmodifiable(sorted);
2025-08-10 01:20:45 +05:30
}
}
2025-08-10 01:20:45 +05:30
// Files provider
@Riverpod(keepAlive: true)
class UserFiles extends _$UserFiles {
@override
Future<List<FileInfo>> build() async {
if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'files');
return const [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return const [];
return _load(api);
2025-09-28 20:41:35 +05:30
}
2025-08-10 01:20:45 +05:30
Future<void> refresh() async {
if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<FileInfo>>([]);
return;
}
final api = ref.read(apiServiceProvider);
if (api == null) {
state = const AsyncData<List<FileInfo>>([]);
return;
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
}
void upsert(FileInfo file) {
final current = state.asData?.value ?? const <FileInfo>[];
final updated = <FileInfo>[...current];
final index = updated.indexWhere((existing) => existing.id == file.id);
if (index >= 0) {
updated[index] = file;
} else {
updated.add(file);
}
state = AsyncData<List<FileInfo>>(_sort(updated));
}
void remove(String id) {
final current = state.asData?.value;
if (current == null) return;
final updated = current
.where((file) => file.id != id)
.toList(growable: true);
state = AsyncData<List<FileInfo>>(_sort(updated));
}
Future<List<FileInfo>> _load(ApiService api) async {
try {
final files = await api.getUserFiles();
return _sort(files);
} catch (e, stackTrace) {
DebugLogger.error(
'files-failed',
scope: 'files',
error: e,
stackTrace: stackTrace,
);
return const [];
}
}
List<FileInfo> _sort(List<FileInfo> input) {
final sorted = [...input];
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return List<FileInfo>.unmodifiable(sorted);
2025-08-10 01:20:45 +05:30
}
}
2025-08-10 01:20:45 +05:30
// File content provider
@riverpod
Future<String> fileContent(Ref ref, String fileId) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'files/content');
throw Exception('Not authenticated');
}
2025-08-10 01:20:45 +05:30
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');
}
}
2025-08-10 01:20:45 +05:30
// Knowledge Base providers
@Riverpod(keepAlive: true)
class KnowledgeBases extends _$KnowledgeBases {
@override
Future<List<KnowledgeBase>> build() async {
if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'knowledge');
return const [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return const [];
return _load(api);
2025-09-28 20:41:35 +05:30
}
2025-08-10 01:20:45 +05:30
Future<void> refresh() async {
if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<KnowledgeBase>>([]);
return;
}
final api = ref.read(apiServiceProvider);
if (api == null) {
state = const AsyncData<List<KnowledgeBase>>([]);
return;
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
}
void upsert(KnowledgeBase knowledgeBase) {
final current = state.asData?.value ?? const <KnowledgeBase>[];
final updated = <KnowledgeBase>[...current];
final index = updated.indexWhere(
(existing) => existing.id == knowledgeBase.id,
);
if (index >= 0) {
updated[index] = knowledgeBase;
} else {
updated.add(knowledgeBase);
}
state = AsyncData<List<KnowledgeBase>>(_sort(updated));
}
void remove(String id) {
final current = state.asData?.value;
if (current == null) return;
final updated = current
.where((knowledgeBase) => knowledgeBase.id != id)
.toList(growable: true);
state = AsyncData<List<KnowledgeBase>>(_sort(updated));
}
Future<List<KnowledgeBase>> _load(ApiService api) async {
try {
final knowledgeBases = await api.getKnowledgeBases();
return _sort(knowledgeBases);
} catch (e, stackTrace) {
DebugLogger.error(
'knowledge-bases-failed',
scope: 'knowledge',
error: e,
stackTrace: stackTrace,
);
return const [];
}
}
List<KnowledgeBase> _sort(List<KnowledgeBase> input) {
final sorted = [...input];
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return List<KnowledgeBase>.unmodifiable(sorted);
2025-08-10 01:20:45 +05:30
}
}
2025-08-10 01:20:45 +05:30
@riverpod
Future<List<KnowledgeBaseItem>> knowledgeBaseItems(Ref ref, String kbId) async {
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'knowledge/items');
return [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
2025-08-10 01:20:45 +05:30
try {
return await api.getKnowledgeBaseItems(kbId);
} catch (e) {
DebugLogger.error('knowledge-items-failed', scope: 'knowledge', error: e);
return [];
}
}
2025-08-10 01:20:45 +05:30
// Audio providers
@Riverpod(keepAlive: true)
Future<List<String>> availableVoices(Ref ref) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'voices');
return [];
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final voices = await api.getAvailableServerVoices();
return voices
.map((v) => (v['name'] ?? v['id'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toList();
2025-08-10 01:20:45 +05:30
} 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 [];
}
}
2025-08-10 01:20:45 +05:30
// Image Generation providers
@Riverpod(keepAlive: true)
Future<List<Map<String, dynamic>>> imageModels(Ref ref) async {
2025-08-10 01:20:45 +05:30
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 [];
}
}
/// Helper function to select cached model based on settings and available models.
/// Used by both chat page and defaultModel provider to ensure consistent behavior.
/// Returns a cached model if available, otherwise returns null.
Future<Model?> selectCachedModel(
OptimizedStorageService storage,
String? desiredModelId,
) async {
try {
final cachedModels = await storage.getLocalModels();
if (cachedModels.isEmpty) return null;
Model? match;
if (desiredModelId != null && desiredModelId.isNotEmpty) {
try {
match = cachedModels.firstWhere(
(model) =>
model.id == desiredModelId ||
model.name.trim() == desiredModelId.trim(),
);
} catch (_) {
match = null;
}
}
return match ?? cachedModels.first;
} catch (error, stackTrace) {
DebugLogger.error(
'cache-select-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
/// Resolves socket transport availability from backend configuration.
///
/// Used by both the sync [socketTransportOptionsProvider] and the
/// [BackendConfigNotifier] to ensure consistent resolution logic.
SocketTransportAvailability _resolveTransportAvailability(
BackendConfig config,
) {
if (config.websocketOnly) {
return const SocketTransportAvailability(
allowPolling: false,
allowWebsocketOnly: true,
);
}
if (config.pollingOnly) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: false,
);
}
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}