From c4a36bb51c92f8de0f7b84b0152d79d41f87f9c8 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:53:14 +0530 Subject: [PATCH] feat(cache): Add lightweight in-memory cache with TTL and LRU eviction --- lib/core/auth/auth_state_manager.dart | 80 ++- lib/core/models/backend_config.dart | 23 +- lib/core/models/model.dart | 38 +- .../models/socket_transport_availability.dart | 23 + lib/core/models/tool.dart | 10 + lib/core/models/user.dart | 12 + lib/core/persistence/hive_boxes.dart | 7 + lib/core/providers/app_providers.dart | 458 +++++++++++---- lib/core/providers/storage_providers.dart | 35 ++ lib/core/services/cache_manager.dart | 121 ++++ .../services/optimized_storage_service.dart | 523 ++++++++++++++++-- lib/core/services/settings_service.dart | 110 ++-- lib/features/chat/views/chat_page.dart | 48 +- .../tools/providers/tools_providers.dart | 52 +- 14 files changed, 1298 insertions(+), 242 deletions(-) create mode 100644 lib/core/models/socket_transport_availability.dart create mode 100644 lib/core/providers/storage_providers.dart create mode 100644 lib/core/services/cache_manager.dart diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index b9678de..44ccb87 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -5,9 +5,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; // Types are used through app_providers.dart import '../providers/app_providers.dart'; import '../models/user.dart'; +import '../services/optimized_storage_service.dart'; import 'token_validator.dart'; import 'auth_cache_manager.dart'; import '../utils/debug_logger.dart'; +import '../utils/user_avatar_utils.dart'; part 'auth_state_manager.g.dart'; @@ -97,12 +99,63 @@ class AuthStateManager extends _$AuthStateManager { state.asData?.value ?? const AuthState(status: AuthStatus.initial); void _set(AuthState next, {bool cache = false}) { + final storage = ref.read(optimizedStorageServiceProvider); + if (next.user != null && next.isAuthenticated) { + // Persist user and avatar asynchronously without blocking state update + unawaited(_persistUserWithAvatar(next, storage)); + } else if (!next.isAuthenticated) { + unawaited(storage.saveLocalUser(null).onError((error, stack) { + DebugLogger.error( + 'Failed to clear local user on logout', + scope: 'auth/persistence', + error: error, + stackTrace: stack, + ); + })); + unawaited(storage.saveLocalUserAvatar(null).onError((error, stack) { + DebugLogger.error( + 'Failed to clear local user avatar on logout', + scope: 'auth/persistence', + error: error, + stackTrace: stack, + ); + })); + } state = AsyncValue.data(next); if (cache) { _cacheManager.cacheAuthState(next); } } + Future _persistUserWithAvatar( + AuthState authState, + OptimizedStorageService storage, + ) async { + try { + final api = ref.read(apiServiceProvider); + final user = authState.user!; + final resolvedAvatar = resolveUserProfileImageUrl( + api, + deriveUserProfileImage(user), + ); + final userWithAvatar = + resolvedAvatar != null && resolvedAvatar != user.profileImage + ? user.copyWith(profileImage: resolvedAvatar) + : user; + await storage.saveLocalUser(userWithAvatar); + if (resolvedAvatar != null) { + await storage.saveLocalUserAvatar(resolvedAvatar); + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to persist user with avatar', + scope: 'auth/persistence', + error: error, + stackTrace: stack, + ); + } + } + void _update( AuthState Function(AuthState current) transform, { bool cache = false, @@ -143,6 +196,25 @@ class AuthStateManager extends _$AuthStateManager { cache: true, ); + try { + final cachedUser = await storage.getLocalUser(); + if (cachedUser != null) { + // Restore cached avatar as well + final cachedAvatar = await storage.getLocalUserAvatar(); + final userWithAvatar = + cachedAvatar != null && + cachedAvatar.isNotEmpty && + cachedUser.profileImage != cachedAvatar + ? cachedUser.copyWith(profileImage: cachedAvatar) + : cachedUser; + _update( + (current) => current.copyWith(user: userWithAvatar), + cache: true, + ); + DebugLogger.auth('Restored user from cache'); + } + } catch (_) {} + // Update API service with token and kick off dependent background work _updateApiServiceToken(token); _preloadDefaultModel(); @@ -706,11 +778,11 @@ class AuthStateManager extends _$AuthStateManager { // Clear active server to force return to server connection page await storage.setActiveServerId(null); - + // Invalidate all auth-related providers to clear cached data ref.invalidate(activeServerProvider); ref.invalidate(serverConfigsProvider); - + // Clear auth cache manager _cacheManager.clearAuthCache(); @@ -725,7 +797,9 @@ class AuthStateManager extends _$AuthStateManager { ), ); - DebugLogger.auth('Logout complete - all data cleared including server configs and custom headers'); + DebugLogger.auth( + 'Logout complete - all data cleared including server configs and custom headers', + ); } catch (e, stack) { DebugLogger.error( 'logout-failed', diff --git a/lib/core/models/backend_config.dart b/lib/core/models/backend_config.dart index 3c04a21..c52db4a 100644 --- a/lib/core/models/backend_config.dart +++ b/lib/core/models/backend_config.dart @@ -35,16 +35,27 @@ class BackendConfig { } Map toJson() { - return {'enable_websocket': enableWebsocket}; + return { + 'enable_websocket': enableWebsocket, + }; } static BackendConfig fromJson(Map json) { bool? enableWebsocket; - final features = json['features']; - if (features is Map) { - final value = features['enable_websocket']; - if (value is bool) { - enableWebsocket = value; + // Try canonical format first + final value = json['enable_websocket']; + if (value is bool) { + enableWebsocket = value; + } + + // Fallback to nested format for backwards compatibility + if (enableWebsocket == null) { + final features = json['features']; + if (features is Map) { + final nestedValue = features['enable_websocket']; + if (nestedValue is bool) { + enableWebsocket = nestedValue; + } } } diff --git a/lib/core/models/model.dart b/lib/core/models/model.dart index d4d1ca8..74f79e9 100644 --- a/lib/core/models/model.dart +++ b/lib/core/models/model.dart @@ -20,6 +20,18 @@ sealed class Model with _$Model { }) = _Model; factory Model.fromJson(Map json) { + final cachedIsMultimodal = switch (json['isMultimodal']) { + final bool value => value, + _ => json['is_multimodal'] is bool ? json['is_multimodal'] as bool : null, + }; + final cachedSupportsStreaming = switch (json['supportsStreaming']) { + final bool value => value, + _ => + json['supports_streaming'] is bool + ? json['supports_streaming'] as bool + : null, + }; + // Handle different response formats from OpenWebUI // Extract architecture info for capabilities @@ -29,8 +41,9 @@ sealed class Model with _$Model { // Determine if multimodal based on architecture final isMultimodal = - modality?.contains('image') == true || - inputModalities?.contains('image') == true; + cachedIsMultimodal ?? + (modality?.contains('image') == true || + inputModalities?.contains('image') == true); // Extract supported parameters robustly (top-level or nested under provider keys) List? supportedParams = @@ -63,7 +76,8 @@ sealed class Model with _$Model { } // Determine streaming support from supported parameters if known - final supportsStreaming = supportedParams?.contains('stream') ?? true; + final supportsStreaming = + cachedSupportsStreaming ?? supportedParams?.contains('stream') ?? true; // Convert supported parameters to List if present final supportedParamsList = supportedParams @@ -154,4 +168,22 @@ sealed class Model with _$Model { toolIds: toolIds, ); } + + Map toJson() { + final data = { + 'id': id, + 'name': name, + 'description': description, + 'isMultimodal': isMultimodal, + 'supportsStreaming': supportsStreaming, + 'supportsRAG': supportsRAG, + 'supported_parameters': supportedParameters, + 'capabilities': capabilities, + 'metadata': metadata, + 'architecture': capabilities?['architecture'], + 'toolIds': toolIds, + }; + data.removeWhere((_, value) => value == null); + return data; + } } diff --git a/lib/core/models/socket_transport_availability.dart b/lib/core/models/socket_transport_availability.dart new file mode 100644 index 0000000..fc69f5e --- /dev/null +++ b/lib/core/models/socket_transport_availability.dart @@ -0,0 +1,23 @@ +class SocketTransportAvailability { + const SocketTransportAvailability({ + required this.allowPolling, + required this.allowWebsocketOnly, + }); + + final bool allowPolling; + final bool allowWebsocketOnly; + + Map toJson() { + return { + 'allowPolling': allowPolling, + 'allowWebsocketOnly': allowWebsocketOnly, + }; + } + + factory SocketTransportAvailability.fromJson(Map json) { + return SocketTransportAvailability( + allowPolling: json['allowPolling'] == true, + allowWebsocketOnly: json['allowWebsocketOnly'] == true, + ); + } +} diff --git a/lib/core/models/tool.dart b/lib/core/models/tool.dart index 1748abd..aa1ef96 100644 --- a/lib/core/models/tool.dart +++ b/lib/core/models/tool.dart @@ -23,4 +23,14 @@ sealed class Tool with _$Tool { meta: json['meta'] as Map?, ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'user_id': userId, + 'meta': meta, + }; + } } diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart index 0500acd..df91e9a 100644 --- a/lib/core/models/user.dart +++ b/lib/core/models/user.dart @@ -30,4 +30,16 @@ sealed class User with _$User { isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true, ); } + + Map toJson() { + return { + 'id': id, + 'username': username, + 'email': email, + 'name': name, + 'profile_image_url': profileImage, + 'role': role, + 'is_active': isActive, + }; + } } diff --git a/lib/core/persistence/hive_boxes.dart b/lib/core/persistence/hive_boxes.dart index ceb821b..33a5fce 100644 --- a/lib/core/persistence/hive_boxes.dart +++ b/lib/core/persistence/hive_boxes.dart @@ -15,6 +15,13 @@ final class HiveStoreKeys { // Cache entries static const String localConversations = 'local_conversations'; + static const String localUser = 'local_user'; + static const String localUserAvatar = 'local_user_avatar'; + static const String localBackendConfig = 'local_backend_config'; + static const String localTransportOptions = 'local_transport_options'; + static const String localTools = 'local_tools'; + static const String localDefaultModel = 'local_default_model'; + static const String localModels = 'local_models'; static const String localFolders = 'local_folders'; static const String attachmentQueueEntries = 'attachment_queue_entries'; static const String taskQueue = 'outbound_task_queue_v1'; diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 16ddeea..ca8765b 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -3,9 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../persistence/persistence_providers.dart'; import '../services/api_service.dart'; import '../auth/auth_state_manager.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; @@ -30,38 +28,13 @@ 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'; + +export 'storage_providers.dart'; part 'app_providers.g.dart'; -// Storage providers -final secureStorageProvider = Provider((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, - ), - ); -}); - -// Optimized storage service provider -final optimizedStorageServiceProvider = Provider(( - ref, -) { - return OptimizedStorageService( - secureStorage: ref.watch(secureStorageProvider), - boxes: ref.watch(hiveBoxesProvider), - workerManager: ref.watch(workerManagerProvider), - ); -}); - // Theme provider @Riverpod(keepAlive: true) class AppThemeMode extends _$AppThemeMode { @@ -175,7 +148,37 @@ final serverConnectionStateProvider = Provider((ref) { ); }); -final backendConfigProvider = FutureProvider((ref) async { +@Riverpod(keepAlive: true) +class BackendConfigNotifier extends _$BackendConfigNotifier { + late final OptimizedStorageService _storage; + + @override + Future build() async { + _storage = ref.watch(optimizedStorageServiceProvider); + final cached = await _storage.getLocalBackendConfig(); + unawaited(_refreshBackendConfig()); + return cached; + } + + Future refresh() => _refreshBackendConfig(); + + Future _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 _loadBackendConfig(Ref ref) async { final api = ref.watch(apiServiceProvider); if (api == null) { return null; @@ -205,21 +208,22 @@ final backendConfigProvider = FutureProvider((ref) async { } catch (_) { return null; } -}); - -class SocketTransportAvailability { - const SocketTransportAvailability({ - required this.allowPolling, - required this.allowWebsocketOnly, - }); - - final bool allowPolling; - final bool allowWebsocketOnly; } +/// 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(( 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, @@ -227,30 +231,16 @@ final socketTransportOptionsProvider = Provider(( ); if (config == null) { - return const SocketTransportAvailability( - allowPolling: true, - allowWebsocketOnly: true, - ); + // Return cached value or defaults when config not available + return storage.getLocalTransportOptionsSync() ?? + const SocketTransportAvailability( + allowPolling: true, + allowWebsocketOnly: true, + ); } - 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, - ); + // Determine transport availability from backend config + return _resolveTransportAvailability(config); }); // API Service provider with unified auth integration @@ -551,52 +541,146 @@ final refreshAuthStateProvider = Provider((ref) { // Model providers @Riverpod(keepAlive: true) -Future> models(Ref 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 { - DebugLogger.log('fetch-start', scope: 'models'); - final models = await api.getModels(); - DebugLogger.log( - 'fetch-ok', - scope: 'models', - data: {'count': models.length}, - ); - return models; - } catch (e) { - DebugLogger.error('fetch-failed', scope: 'models', error: e); - - // 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'); +class Models extends _$Models { + @override + Future> build() async { + // Reviewer mode returns mock models + if (ref.watch(reviewerModeProvider)) { + return _demoModels(); } - return []; + if (!ref.watch(isAuthenticatedProvider2)) { + DebugLogger.log('skip-unauthed', scope: 'models'); + _persistModelsAsync(const []); + 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; + } + DebugLogger.log('cache-empty', scope: 'models/cache'); + } 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 []); + return const []; + } + + final fresh = await _load(api); + return fresh; } + + Future refresh() async { + if (ref.read(reviewerModeProvider)) { + state = AsyncData>(_demoModels()); + return; + } + if (!ref.read(isAuthenticatedProvider2)) { + state = const AsyncData>([]); + _persistModelsAsync(const []); + return; + } + final api = ref.read(apiServiceProvider); + if (api == null) { + state = const AsyncData>([]); + _persistModelsAsync(const []); + return; + } + final result = await AsyncValue.guard(() => _load(api)); + if (!ref.mounted) return; + state = result; + } + + Future> _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'); + } + + return const []; + } + } + + void _persistModelsAsync(List 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, + ); + }), + ); + } + + List _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'], + ), + ]; } @Riverpod(keepAlive: true) @@ -1243,6 +1327,7 @@ Future defaultModel(Ref ref) async { // Initialize the settings watcher (side-effect only) ref.read(_settingsWatcherProvider); + final storage = ref.read(optimizedStorageServiceProvider); // Read settings without subscribing to rebuilds to avoid watch/await hazards final reviewerMode = ref.read(reviewerModeProvider); if (reviewerMode) { @@ -1287,6 +1372,20 @@ Future defaultModel(Ref ref) async { DebugLogger.log('api-available', scope: 'models/default'); 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 (_) {} + // Respect manual selection if present if (ref.read(isManualModelSelectionProvider)) { final current = ref.read(selectedModelProvider); @@ -1298,18 +1397,46 @@ Future defaultModel(Ref ref) async { final storedDefaultId = await SettingsService.getDefaultModel(); if (storedDefaultId != null && storedDefaultId.isNotEmpty) { if (!ref.read(isManualModelSelectionProvider)) { - final placeholder = Model( - id: storedDefaultId, - name: storedDefaultId, - supportsStreaming: true, - ); - ref.read(selectedModelProvider.notifier).set(placeholder); + 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}, + ); + } } // Reconcile against real models in background Future.microtask(() async { try { if (!ref.mounted) return; - final models = await ref.read(modelsProvider.future); + List models; + final modelsAsync = ref.read(modelsProvider); + if (modelsAsync.hasValue) { + models = modelsAsync.value ?? const []; + } else { + models = await ref.read(modelsProvider.future); + } if (!ref.mounted) return; Model? resolved; @@ -1326,6 +1453,16 @@ Future defaultModel(Ref ref) async { if (!ref.mounted) return; if (resolved != null && !ref.read(isManualModelSelectionProvider)) { 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, + ); + }), + ); DebugLogger.log( 'reconcile', scope: 'models/default', @@ -1355,6 +1492,16 @@ Future defaultModel(Ref ref) async { supportsStreaming: true, ); 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, + ); + }), + ); } // Reconcile against real models in background Future.microtask(() async { @@ -1377,6 +1524,16 @@ Future defaultModel(Ref ref) async { if (!ref.mounted) return; if (resolved != null && !ref.read(isManualModelSelectionProvider)) { 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, + ); + }), + ); DebugLogger.log( 'reconcile', scope: 'models/default', @@ -1410,6 +1567,16 @@ Future defaultModel(Ref ref) async { final selectedModel = models.first; if (!ref.read(isManualModelSelectionProvider)) { 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, + ); + }), + ); DebugLogger.log( 'fallback-selected', scope: 'models/default', @@ -2158,3 +2325,66 @@ Future>> imageModels(Ref ref) async { 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 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, + ); +} diff --git a/lib/core/providers/storage_providers.dart b/lib/core/providers/storage_providers.dart new file mode 100644 index 0000000..c8de3bc --- /dev/null +++ b/lib/core/providers/storage_providers.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../persistence/persistence_providers.dart'; +import '../services/optimized_storage_service.dart'; +import '../services/worker_manager.dart'; + +/// Provides a shared [FlutterSecureStorage] instance with platform-specific +/// configuration. +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + sharedPreferencesName: 'conduit_secure_prefs', + preferencesKeyPrefix: 'conduit_', + // Avoid auto-wipe on transient errors; handled at call sites instead. + resetOnError: false, + ), + iOptions: IOSOptions( + accountName: 'conduit_secure_storage', + synchronizable: false, + ), + ); +}); + +/// Optimized storage service backed by Hive plus secure storage. +final optimizedStorageServiceProvider = Provider(( + ref, +) { + return OptimizedStorageService( + secureStorage: ref.watch(secureStorageProvider), + boxes: ref.watch(hiveBoxesProvider), + workerManager: ref.watch(workerManagerProvider), + ); +}); diff --git a/lib/core/services/cache_manager.dart b/lib/core/services/cache_manager.dart new file mode 100644 index 0000000..95fc535 --- /dev/null +++ b/lib/core/services/cache_manager.dart @@ -0,0 +1,121 @@ +/// Lightweight in-memory cache with TTL enforcement and LRU eviction. +/// +/// Centralizes cache handling so services can avoid duplicating map and +/// timestamp bookkeeping. Entries expire after [defaultTtl] and the cache is +/// trimmed to [maxEntries] using least-recently-used eviction. +class CacheManager { + CacheManager({ + Duration defaultTtl = const Duration(minutes: 5), + int maxEntries = 64, + }) : _defaultTtl = defaultTtl, + _maxEntries = maxEntries; + + final Duration _defaultTtl; + final int _maxEntries; + final Map _entries = {}; + + /// Reads a cached value and returns whether the lookup was a hit. + ({bool hit, T? value}) lookup(String key) { + final record = _getRecord(key); + if (record == null) return (hit: false, value: null); + return (hit: true, value: record.value as T?); + } + + /// Stores [value] with an optional [ttl] override. + void write(String key, T? value, {Duration? ttl}) { + final now = DateTime.now(); + _entries[key] = _CacheRecord( + value: value, + ttl: ttl ?? _defaultTtl, + createdAt: now, + lastAccessed: now, + ); + _enforceLimits(now); + } + + /// Removes a single cached entry. + void invalidate(String key) { + _entries.remove(key); + } + + /// Removes entries that match [predicate]. + void invalidateMatching(bool Function(String key) predicate) { + _entries.removeWhere((key, _) => predicate(key)); + } + + /// Clears all cached entries. + void clear() { + _entries.clear(); + } + + /// Current cache statistics for debugging and health checks. + Map stats() { + final now = DateTime.now(); + return { + 'size': _entries.length, + 'maxEntries': _maxEntries, + 'defaultTtlSeconds': _defaultTtl.inSeconds, + 'entries': _entries.map((key, record) { + final age = now.difference(record.createdAt); + final idle = now.difference(record.lastAccessed); + return MapEntry(key, { + 'ageSeconds': age.inSeconds, + 'idleSeconds': idle.inSeconds, + 'ttlSeconds': record.ttl.inSeconds, + }); + }), + }; + } + + _CacheRecord? _getRecord(String key) { + final record = _entries[key]; + if (record == null) return null; + + final now = DateTime.now(); + if (record.isExpired(now)) { + _entries.remove(key); + return null; + } + + record.touch(now); + return record; + } + + void _enforceLimits(DateTime now) { + _removeExpired(now); + if (_entries.length <= _maxEntries) return; + + final oldestFirst = _entries.entries.toList() + ..sort((a, b) => a.value.lastAccessed.compareTo(b.value.lastAccessed)); + final overflow = oldestFirst.length - _maxEntries; + for (var i = 0; i < overflow; i++) { + _entries.remove(oldestFirst[i].key); + } + } + + void _removeExpired(DateTime now) { + _entries.removeWhere((_, record) => record.isExpired(now)); + } +} + +class _CacheRecord { + _CacheRecord({ + required this.value, + required this.ttl, + required this.createdAt, + required this.lastAccessed, + }); + + final Object? value; + final Duration ttl; + final DateTime createdAt; + DateTime lastAccessed; + + bool isExpired(DateTime now) { + return now.difference(createdAt) > ttl; + } + + void touch(DateTime now) { + lastAccessed = now; + } +} diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index 4dc0585..000026f 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -3,12 +3,18 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive_ce/hive.dart'; +import '../models/backend_config.dart'; import '../models/conversation.dart'; import '../models/folder.dart'; +import '../models/model.dart'; import '../models/server_config.dart'; +import '../models/user.dart'; +import '../models/tool.dart'; +import '../models/socket_transport_availability.dart'; import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import '../utils/debug_logger.dart'; +import 'cache_manager.dart'; import 'secure_credential_storage.dart'; import 'worker_manager.dart'; @@ -34,6 +40,7 @@ class OptimizedStorageService { final Box _metadataBox; final SecureCredentialStorage _secureCredentialStorage; final WorkerManager _workerManager; + final CacheManager _cacheManager = CacheManager(maxEntries: 64); static const String _authTokenKey = 'auth_token_v3'; static const String _activeServerIdKey = PreferenceKeys.activeServerId; @@ -41,22 +48,25 @@ class OptimizedStorageService { static const String _themePaletteKey = PreferenceKeys.themePalette; static const String _localeCodeKey = PreferenceKeys.localeCode; static const String _localConversationsKey = HiveStoreKeys.localConversations; + static const String _localUserKey = HiveStoreKeys.localUser; + static const String _localUserAvatarKey = HiveStoreKeys.localUserAvatar; + static const String _localBackendConfigKey = HiveStoreKeys.localBackendConfig; + static const String _localTransportOptionsKey = + HiveStoreKeys.localTransportOptions; + static const String _localToolsKey = HiveStoreKeys.localTools; + static const String _localDefaultModelKey = HiveStoreKeys.localDefaultModel; + static const String _localModelsKey = HiveStoreKeys.localModels; static const String _localFoldersKey = HiveStoreKeys.localFolders; static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen; static const String _reviewerModeKey = PreferenceKeys.reviewerMode; - final Map _cache = {}; - final Map _cacheTimestamps = {}; - static const Duration _cacheTimeout = Duration(minutes: 5); - // --------------------------------------------------------------------------- // Auth token APIs (secure storage + in-memory cache) // --------------------------------------------------------------------------- Future saveAuthToken(String token) async { try { await _secureCredentialStorage.saveAuthToken(token); - _cache[_authTokenKey] = token; - _cacheTimestamps[_authTokenKey] = DateTime.now(); + _cacheManager.write(_authTokenKey, token); DebugLogger.log( 'Auth token saved and cached', scope: 'storage/optimized', @@ -71,19 +81,17 @@ class OptimizedStorageService { } Future getAuthToken() async { - if (_isCacheValid(_authTokenKey)) { - final cached = _cache[_authTokenKey] as String?; - if (cached != null) { - DebugLogger.log('Using cached auth token', scope: 'storage/optimized'); - return cached; - } + final (hit: hasCachedToken, value: cachedToken) = _cacheManager + .lookup(_authTokenKey); + if (hasCachedToken) { + DebugLogger.log('Using cached auth token', scope: 'storage/optimized'); + return cachedToken; } try { final token = await _secureCredentialStorage.getAuthToken(); if (token != null) { - _cache[_authTokenKey] = token; - _cacheTimestamps[_authTokenKey] = DateTime.now(); + _cacheManager.write(_authTokenKey, token); } return token; } catch (error) { @@ -98,8 +106,7 @@ class OptimizedStorageService { Future deleteAuthToken() async { try { await _secureCredentialStorage.deleteAuthToken(); - _cache.remove(_authTokenKey); - _cacheTimestamps.remove(_authTokenKey); + _cacheManager.invalidate(_authTokenKey); DebugLogger.log( 'Auth token deleted and cache cleared', scope: 'storage/optimized', @@ -129,8 +136,7 @@ class OptimizedStorageService { password: password, ); - _cache['has_credentials'] = true; - _cacheTimestamps['has_credentials'] = DateTime.now(); + _cacheManager.write('has_credentials', true); DebugLogger.log( 'Credentials saved via optimized storage', @@ -148,8 +154,7 @@ class OptimizedStorageService { Future?> getSavedCredentials() async { try { final credentials = await _secureCredentialStorage.getSavedCredentials(); - _cache['has_credentials'] = credentials != null; - _cacheTimestamps['has_credentials'] = DateTime.now(); + _cacheManager.write('has_credentials', credentials != null); return credentials; } catch (error) { DebugLogger.log( @@ -163,8 +168,7 @@ class OptimizedStorageService { Future deleteSavedCredentials() async { try { await _secureCredentialStorage.deleteSavedCredentials(); - _cache.remove('has_credentials'); - _cacheTimestamps.remove('has_credentials'); + _cacheManager.invalidate('has_credentials'); DebugLogger.log( 'Credentials deleted via optimized storage', scope: 'storage/optimized', @@ -180,8 +184,10 @@ class OptimizedStorageService { } Future hasCredentials() async { - if (_isCacheValid('has_credentials')) { - return _cache['has_credentials'] == true; + final (hit: hasCachedValue, value: hasCredentials) = _cacheManager + .lookup('has_credentials'); + if (hasCachedValue) { + return hasCredentials == true; } final credentials = await getSavedCredentials(); return credentials != null; @@ -194,8 +200,7 @@ class OptimizedStorageService { try { final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList()); await _secureCredentialStorage.saveServerConfigs(jsonString); - _cache['server_config_count'] = configs.length; - _cacheTimestamps['server_config_count'] = DateTime.now(); + _cacheManager.write('server_config_count', configs.length); DebugLogger.log( 'Server configs saved (${configs.length} entries)', scope: 'storage/optimized', @@ -213,8 +218,7 @@ class OptimizedStorageService { try { final jsonString = await _secureCredentialStorage.getServerConfigs(); if (jsonString == null || jsonString.isEmpty) { - _cache['server_config_count'] = 0; - _cacheTimestamps['server_config_count'] = DateTime.now(); + _cacheManager.write('server_config_count', 0); return const []; } @@ -222,8 +226,7 @@ class OptimizedStorageService { final configs = decoded .map((item) => ServerConfig.fromJson(item)) .toList(); - _cache['server_config_count'] = configs.length; - _cacheTimestamps['server_config_count'] = DateTime.now(); + _cacheManager.write('server_config_count', configs.length); return configs; } catch (error) { DebugLogger.log( @@ -240,17 +243,18 @@ class OptimizedStorageService { } else { await _preferencesBox.delete(_activeServerIdKey); } - _cache[_activeServerIdKey] = serverId; - _cacheTimestamps[_activeServerIdKey] = DateTime.now(); + _cacheManager.write(_activeServerIdKey, serverId); } Future getActiveServerId() async { - if (_isCacheValid(_activeServerIdKey)) { - return _cache[_activeServerIdKey] as String?; + final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup( + _activeServerIdKey, + ); + if (hasCachedId) { + return cachedId; } final serverId = _preferencesBox.get(_activeServerIdKey) as String?; - _cache[_activeServerIdKey] = serverId; - _cacheTimestamps[_activeServerIdKey] = DateTime.now(); + _cacheManager.write(_activeServerIdKey, serverId); return serverId; } @@ -392,6 +396,378 @@ class OptimizedStorageService { } } + Future getLocalUser() async { + try { + final stored = _cachesBox.get(_localUserKey); + if (stored == null) return null; + if (stored is String) { + final decoded = jsonDecode(stored); + if (decoded is Map) { + return User.fromJson(decoded); + } + } else if (stored is Map) { + return User.fromJson(stored); + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local user', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future saveLocalUser(User? user) async { + try { + if (user == null) { + await _cachesBox.delete(_localUserKey); + await _cachesBox.delete(_localUserAvatarKey); + return; + } + final serialized = jsonEncode(user.toJson()); + await _cachesBox.put(_localUserKey, serialized); + DebugLogger.log('Saved local user profile', scope: 'storage/optimized'); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local user', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + Future getLocalUserAvatar() async { + try { + final stored = _cachesBox.get(_localUserAvatarKey); + if (stored is String && stored.isNotEmpty) { + return stored; + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local user avatar', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future saveLocalUserAvatar(String? avatarUrl) async { + try { + if (avatarUrl == null || avatarUrl.isEmpty) { + await _cachesBox.delete(_localUserAvatarKey); + return; + } + await _cachesBox.put(_localUserAvatarKey, avatarUrl); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local user avatar', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + Future getLocalBackendConfig() async { + try { + final stored = _cachesBox.get(_localBackendConfigKey); + if (stored == null) return null; + final activeServerId = await getActiveServerId(); + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(activeServerId, ownerServerId)) { + return null; + } + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return BackendConfig.fromJson(decoded); + } + } else if (payload is Map) { + return BackendConfig.fromJson(Map.from(payload)); + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local backend config', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future saveLocalBackendConfig(BackendConfig? config) async { + try { + if (config == null) { + await _cachesBox.delete(_localBackendConfigKey); + return; + } + final serialized = jsonEncode(config.toJson()); + await _cachesBox.put( + _localBackendConfigKey, + _wrapServerScoped(serialized), + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local backend config', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + Future getLocalTransportOptions() async { + try { + final stored = _cachesBox.get(_localTransportOptionsKey); + if (stored == null) return null; + final activeServerId = await getActiveServerId(); + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(activeServerId, ownerServerId)) { + return null; + } + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return _transportFromJson(decoded); + } + } else if (payload is Map) { + return _transportFromJson(Map.from(payload)); + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local transport options', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future saveLocalTransportOptions( + SocketTransportAvailability? options, + ) async { + try { + if (options == null) { + await _cachesBox.delete(_localTransportOptionsKey); + return; + } + final json = { + 'allowPolling': options.allowPolling, + 'allowWebsocketOnly': options.allowWebsocketOnly, + }; + await _cachesBox.put( + _localTransportOptionsKey, + _wrapServerScoped(jsonEncode(json)), + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local transport options', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + SocketTransportAvailability? getLocalTransportOptionsSync() { + try { + final stored = _cachesBox.get(_localTransportOptionsKey); + if (stored == null) return null; + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(_readActiveServerIdSync(), ownerServerId)) { + return null; + } + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return _transportFromJson(decoded); + } + } else if (payload is Map) { + return _transportFromJson(Map.from(payload)); + } + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local transport options sync', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future> getLocalModels() async { + try { + final stored = _cachesBox.get(_localModelsKey); + if (stored == null) { + return const []; + } + final activeServerId = await getActiveServerId(); + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(activeServerId, ownerServerId)) { + return const []; + } + if (payload == null) return const []; + final parsed = await _workerManager + .schedule, List>>( + _decodeStoredJsonListWorker, + {'stored': payload}, + debugLabel: 'decode_local_models', + ); + return parsed.map(Model.fromJson).toList(growable: false); + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local models', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + return const []; + } + } + + Future saveLocalModels(List models) async { + try { + final jsonReady = models.map((model) => model.toJson()).toList(); + final serialized = await _workerManager + .schedule, String>(_encodeJsonListWorker, { + 'items': jsonReady, + }, debugLabel: 'encode_local_models'); + await _cachesBox.put(_localModelsKey, _wrapServerScoped(serialized)); + DebugLogger.log( + 'Saved ${models.length} local models', + scope: 'storage/optimized', + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local models', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + Future> getLocalTools() async { + try { + final stored = _cachesBox.get(_localToolsKey); + if (stored == null) return const []; + final activeServerId = await getActiveServerId(); + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(activeServerId, ownerServerId)) { + return const []; + } + if (payload == null) return const []; + final parsed = await _workerManager + .schedule, List>>( + _decodeStoredJsonListWorker, + {'stored': payload}, + debugLabel: 'decode_local_tools', + ); + return parsed.map(Tool.fromJson).toList(growable: false); + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local tools', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + return const []; + } + } + + Future saveLocalTools(List tools) async { + try { + final jsonReady = tools.map((tool) => tool.toJson()).toList(); + final serialized = await _workerManager + .schedule, String>(_encodeJsonListWorker, { + 'items': jsonReady, + }, debugLabel: 'encode_local_tools'); + await _cachesBox.put(_localToolsKey, _wrapServerScoped(serialized)); + DebugLogger.log( + 'Saved ${tools.length} local tools', + scope: 'storage/optimized', + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local tools', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + + Future getLocalDefaultModel() async { + try { + final stored = _cachesBox.get(_localDefaultModelKey); + if (stored == null) return null; + final activeServerId = await getActiveServerId(); + final (payload, ownerServerId) = _unwrapServerScoped(stored); + if (!_matchesActiveServer(activeServerId, ownerServerId)) { + return null; + } + Model? parsed; + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + parsed = Model.fromJson(decoded); + } + } else if (payload is Map) { + parsed = Model.fromJson(Map.from(payload)); + } + if (parsed == null) return null; + + final parsedModel = parsed; + final cachedModels = await getLocalModels(); + final hasMatch = cachedModels.any( + (model) => + model.id == parsedModel.id || + model.name.trim() == parsedModel.name.trim(), + ); + if (cachedModels.isNotEmpty && !hasMatch) { + return null; + } + return parsedModel; + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local default model', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + return null; + } + + Future saveLocalDefaultModel(Model? model) async { + try { + if (model == null) { + await _cachesBox.delete(_localDefaultModelKey); + return; + } + final serialized = jsonEncode(model.toJson()); + await _cachesBox.put( + _localDefaultModelKey, + _wrapServerScoped(serialized), + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local default model', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + // --------------------------------------------------------------------------- // Batch operations // --------------------------------------------------------------------------- @@ -402,18 +778,19 @@ class OptimizedStorageService { deleteAuthToken(), deleteSavedCredentials(), _preferencesBox.delete(_activeServerIdKey), + _cachesBox.delete(_localUserKey), + _cachesBox.delete(_localUserAvatarKey), + _cachesBox.delete(_localBackendConfigKey), + _cachesBox.delete(_localTransportOptionsKey), + _cachesBox.delete(_localToolsKey), + _cachesBox.delete(_localDefaultModelKey), + _cachesBox.delete(_localModelsKey), // Clear server configurations (which include custom headers) _secureCredentialStorage.clearAll(), ]); - _cache.removeWhere( - (key, _) => - key.contains('auth') || - key.contains('credentials') || - key.contains('server'), - ); - _cacheTimestamps.removeWhere( - (key, _) => + _cacheManager.invalidateMatching( + (key) => key.contains('auth') || key.contains('credentials') || key.contains('server'), @@ -434,8 +811,7 @@ class OptimizedStorageService { _attachmentQueueBox.clear(), ]); - _cache.clear(); - _cacheTimestamps.clear(); + _cacheManager.clear(); // Preserve migration metadata final migrationVersion = @@ -462,22 +838,53 @@ class OptimizedStorageService { } // --------------------------------------------------------------------------- - // Cache helpers + // Server scoping helpers // --------------------------------------------------------------------------- - bool _isCacheValid(String key) { - final timestamp = _cacheTimestamps[key]; - if (timestamp == null) { - return false; + (Object?, String?) _unwrapServerScoped(Object? stored) { + if (stored is Map && stored.containsKey('data')) { + final serverId = stored['serverId']; + return (stored['data'], serverId is String ? serverId : null); } - return DateTime.now().difference(timestamp) < _cacheTimeout; + return (stored, null); } + Map _wrapServerScoped(Object data) { + return {'data': data, 'serverId': _readActiveServerIdSync()}; + } + + bool _matchesActiveServer(String? activeServerId, String? ownerServerId) { + if (ownerServerId == null || ownerServerId.isEmpty) { + return activeServerId == null; + } + return activeServerId == ownerServerId; + } + + String? _readActiveServerIdSync() { + final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup( + _activeServerIdKey, + ); + if (hasCachedId) { + return cachedId; + } + return _preferencesBox.get(_activeServerIdKey) as String?; + } + + // --------------------------------------------------------------------------- + // Cache helpers + // --------------------------------------------------------------------------- void clearCache() { - _cache.clear(); - _cacheTimestamps.clear(); + _cacheManager.clear(); DebugLogger.log('Storage cache cleared', scope: 'storage/optimized'); } + SocketTransportAvailability? _transportFromJson(Map json) { + try { + return SocketTransportAvailability.fromJson(json); + } catch (_) { + return null; + } + } + // --------------------------------------------------------------------------- // Legacy migration hooks (no-op) // --------------------------------------------------------------------------- @@ -500,13 +907,7 @@ class OptimizedStorageService { } Map getStorageStats() { - return { - 'cacheSize': _cache.length, - 'cachedKeys': _cache.keys.toList(), - 'lastAccess': _cacheTimestamps.entries - .map((entry) => '${entry.key}: ${entry.value}') - .toList(), - }; + return _cacheManager.stats(); } } diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index ae20c71..d34d180 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -1,7 +1,10 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:hive_ce/hive.dart'; +import '../persistence/hive_bootstrap.dart'; import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import 'animation_service.dart'; @@ -126,47 +129,7 @@ class SettingsService { /// Load all settings static Future loadSettings() { final box = _preferencesBox(); - return Future.value( - AppSettings( - reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false, - animationSpeed: - (box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0, - hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true, - highContrast: (box.get(_highContrastKey) as bool?) ?? false, - largeText: (box.get(_largeTextKey) as bool?) ?? false, - darkMode: (box.get(_darkModeKey) as bool?) ?? true, - defaultModel: box.get(_defaultModelKey) as String?, - voiceLocaleId: box.get(_voiceLocaleKey) as String?, - voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false, - voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false, - socketTransportMode: - box.get(_socketTransportModeKey, defaultValue: 'ws') as String, - quickPills: List.from( - (box.get(_quickPillsKey) as List?) ?? const [], - ), - sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false, - ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?, - ttsSpeechRate: - (box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5, - ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0, - ttsVolume: - (box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0, - ttsEngine: _parseTtsEngine( - box.get(PreferenceKeys.ttsEngine) as String?, - ), - ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?, - ttsServerVoiceName: - box.get(PreferenceKeys.ttsServerVoiceName) as String?, - sttPreference: _parseSttPreference( - box.get(PreferenceKeys.voiceSttPreference) as String?, - ), - voiceSilenceDuration: - (box.get(_voiceSilenceDurationKey) as int? ?? 2000).clamp( - 300, - 3000, - ), - ), - ); + return Future.value(_loadSettingsSync(box)); } /// Save all settings @@ -379,6 +342,40 @@ class SettingsService { // Ensure reasonable bounds return baseScale.clamp(0.8, 3.0); } + + static AppSettings _loadSettingsSync(Box box) { + return AppSettings( + reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false, + animationSpeed: (box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0, + hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true, + highContrast: (box.get(_highContrastKey) as bool?) ?? false, + largeText: (box.get(_largeTextKey) as bool?) ?? false, + darkMode: (box.get(_darkModeKey) as bool?) ?? true, + defaultModel: box.get(_defaultModelKey) as String?, + voiceLocaleId: box.get(_voiceLocaleKey) as String?, + voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false, + voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false, + socketTransportMode: + box.get(_socketTransportModeKey, defaultValue: 'ws') as String, + quickPills: List.from( + (box.get(_quickPillsKey) as List?) ?? const [], + ), + sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false, + ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?, + ttsSpeechRate: + (box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5, + ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0, + ttsVolume: (box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0, + ttsEngine: _parseTtsEngine(box.get(PreferenceKeys.ttsEngine) as String?), + ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?, + ttsServerVoiceName: box.get(PreferenceKeys.ttsServerVoiceName) as String?, + sttPreference: _parseSttPreference( + box.get(PreferenceKeys.voiceSttPreference) as String?, + ), + voiceSilenceDuration: (box.get(_voiceSilenceDurationKey) as int? ?? 2000) + .clamp(300, 3000), + ); + } } /// Sentinel class to detect when defaultModel parameter is not provided @@ -562,23 +559,36 @@ bool _listEquals(List a, List b) { /// Provider for app settings @Riverpod(keepAlive: true) class AppSettingsNotifier extends _$AppSettingsNotifier { - bool _initialized = false; + Future? _pendingLoad; @override AppSettings build() { - if (!_initialized) { - _initialized = true; - Future.microtask(_loadSettings); + if (Hive.isBoxOpen(HiveBoxNames.preferences)) { + final box = Hive.box(HiveBoxNames.preferences); + return SettingsService._loadSettingsSync(box); } + + _pendingLoad ??= _hydrateFromHive(); return const AppSettings(); } - Future _loadSettings() async { - final settings = await SettingsService.loadSettings(); - if (!ref.mounted) { - return; + Future _hydrateFromHive() async { + try { + await HiveBootstrap.instance.ensureInitialized(); + if (!ref.mounted) return; + final box = Hive.box(HiveBoxNames.preferences); + state = SettingsService._loadSettingsSync(box); + } catch (error, stackTrace) { + developer.log( + 'Failed to hydrate settings', + name: 'AppSettingsNotifier', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + } finally { + _pendingLoad = null; } - state = settings; } Future setReduceMotion(bool value) async { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 06fad40..749aa44 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -18,7 +18,6 @@ import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/android_assistant_handler.dart'; - import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; @@ -83,6 +82,42 @@ class _ChatPageState extends ConsumerState { return fileSize <= (maxSizeMB * 1024 * 1024); } + Future _trySelectCachedModel() async { + final existing = ref.read(selectedModelProvider); + if (existing != null) { + return existing; + } + + try { + final storage = ref.read(optimizedStorageServiceProvider); + // Prefer the stored default model ID/name if available + final settingsDesired = ref.read(appSettingsProvider).defaultModel; + final storedDesired = await SettingsService.getDefaultModel().catchError( + (_) => null, + ); + final desiredId = settingsDesired ?? storedDesired; + + final match = await selectCachedModel(storage, desiredId); + if (match != null) { + ref.read(selectedModelProvider.notifier).set(match); + DebugLogger.log( + 'cache-select', + scope: 'chat/model', + data: {'name': match.name, 'source': 'cache'}, + ); + } + return match; + } catch (error, stackTrace) { + DebugLogger.error( + 'cache-select-failed', + scope: 'chat/model', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + void startNewChat() { // Clear current conversation ref.read(chatMessagesProvider.notifier).clearMessages(); @@ -110,6 +145,17 @@ class _ChatPageState extends ConsumerState { return; } + // Fast path: try cached models + stored default before waiting on providers + final cached = await _trySelectCachedModel(); + if (cached != null) { + // Still continue to reconcile against remote models below + DebugLogger.log( + 'cache-hit', + scope: 'chat/model', + data: {'name': cached.name}, + ); + } + DebugLogger.log('auto-select-start', scope: 'chat/model'); try { diff --git a/lib/features/tools/providers/tools_providers.dart b/lib/features/tools/providers/tools_providers.dart index d16979c..041df95 100644 --- a/lib/features/tools/providers/tools_providers.dart +++ b/lib/features/tools/providers/tools_providers.dart @@ -1,15 +1,59 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:conduit/core/models/tool.dart'; +import 'package:conduit/core/providers/storage_providers.dart'; import 'package:conduit/core/services/tools_service.dart'; part 'tools_providers.g.dart'; @Riverpod(keepAlive: true) -Future> toolsList(Ref ref) async { - final toolsService = ref.watch(toolsServiceProvider); - if (toolsService == null) return []; - return await toolsService.getTools(); +class ToolsList extends _$ToolsList { + @override + Future> build() async { + final storage = ref.watch(optimizedStorageServiceProvider); + final toolsService = ref.watch(toolsServiceProvider); + final cached = await storage.getLocalTools(); + + if (cached.isNotEmpty) { + _scheduleWarmRefresh(toolsService); + return cached; + } + + if (toolsService == null) { + return const []; + } + + return _fetchAndPersist(toolsService); + } + + Future refresh() async { + final toolsService = ref.read(toolsServiceProvider); + if (toolsService == null) { + return; + } + final result = await AsyncValue.guard(() => _fetchAndPersist(toolsService)); + if (!ref.mounted) return; + state = result; + } + + void _scheduleWarmRefresh(ToolsService? service) { + if (service == null) { + return; + } + Future.microtask(() async { + await refresh(); + }); + } + + Future> _fetchAndPersist(ToolsService service) async { + final tools = await service.getTools(); + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveLocalTools(tools); + return tools; + } } @Riverpod(keepAlive: true)