feat(cache): Add lightweight in-memory cache with TTL and LRU eviction
This commit is contained in:
@@ -5,9 +5,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
// Types are used through app_providers.dart
|
// Types are used through app_providers.dart
|
||||||
import '../providers/app_providers.dart';
|
import '../providers/app_providers.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
import '../services/optimized_storage_service.dart';
|
||||||
import 'token_validator.dart';
|
import 'token_validator.dart';
|
||||||
import 'auth_cache_manager.dart';
|
import 'auth_cache_manager.dart';
|
||||||
import '../utils/debug_logger.dart';
|
import '../utils/debug_logger.dart';
|
||||||
|
import '../utils/user_avatar_utils.dart';
|
||||||
|
|
||||||
part 'auth_state_manager.g.dart';
|
part 'auth_state_manager.g.dart';
|
||||||
|
|
||||||
@@ -97,12 +99,63 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
state.asData?.value ?? const AuthState(status: AuthStatus.initial);
|
state.asData?.value ?? const AuthState(status: AuthStatus.initial);
|
||||||
|
|
||||||
void _set(AuthState next, {bool cache = false}) {
|
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);
|
state = AsyncValue.data(next);
|
||||||
if (cache) {
|
if (cache) {
|
||||||
_cacheManager.cacheAuthState(next);
|
_cacheManager.cacheAuthState(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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(
|
void _update(
|
||||||
AuthState Function(AuthState current) transform, {
|
AuthState Function(AuthState current) transform, {
|
||||||
bool cache = false,
|
bool cache = false,
|
||||||
@@ -143,6 +196,25 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
cache: true,
|
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
|
// Update API service with token and kick off dependent background work
|
||||||
_updateApiServiceToken(token);
|
_updateApiServiceToken(token);
|
||||||
_preloadDefaultModel();
|
_preloadDefaultModel();
|
||||||
@@ -706,11 +778,11 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
|
|
||||||
// Clear active server to force return to server connection page
|
// Clear active server to force return to server connection page
|
||||||
await storage.setActiveServerId(null);
|
await storage.setActiveServerId(null);
|
||||||
|
|
||||||
// Invalidate all auth-related providers to clear cached data
|
// Invalidate all auth-related providers to clear cached data
|
||||||
ref.invalidate(activeServerProvider);
|
ref.invalidate(activeServerProvider);
|
||||||
ref.invalidate(serverConfigsProvider);
|
ref.invalidate(serverConfigsProvider);
|
||||||
|
|
||||||
// Clear auth cache manager
|
// Clear auth cache manager
|
||||||
_cacheManager.clearAuthCache();
|
_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) {
|
} catch (e, stack) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'logout-failed',
|
'logout-failed',
|
||||||
|
|||||||
@@ -35,16 +35,27 @@ class BackendConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{'enable_websocket': enableWebsocket};
|
return <String, dynamic>{
|
||||||
|
'enable_websocket': enableWebsocket,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static BackendConfig fromJson(Map<String, dynamic> json) {
|
static BackendConfig fromJson(Map<String, dynamic> json) {
|
||||||
bool? enableWebsocket;
|
bool? enableWebsocket;
|
||||||
final features = json['features'];
|
// Try canonical format first
|
||||||
if (features is Map<String, dynamic>) {
|
final value = json['enable_websocket'];
|
||||||
final value = features['enable_websocket'];
|
if (value is bool) {
|
||||||
if (value is bool) {
|
enableWebsocket = value;
|
||||||
enableWebsocket = value;
|
}
|
||||||
|
|
||||||
|
// Fallback to nested format for backwards compatibility
|
||||||
|
if (enableWebsocket == null) {
|
||||||
|
final features = json['features'];
|
||||||
|
if (features is Map<String, dynamic>) {
|
||||||
|
final nestedValue = features['enable_websocket'];
|
||||||
|
if (nestedValue is bool) {
|
||||||
|
enableWebsocket = nestedValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ sealed class Model with _$Model {
|
|||||||
}) = _Model;
|
}) = _Model;
|
||||||
|
|
||||||
factory Model.fromJson(Map<String, dynamic> json) {
|
factory Model.fromJson(Map<String, dynamic> 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
|
// Handle different response formats from OpenWebUI
|
||||||
|
|
||||||
// Extract architecture info for capabilities
|
// Extract architecture info for capabilities
|
||||||
@@ -29,8 +41,9 @@ sealed class Model with _$Model {
|
|||||||
|
|
||||||
// Determine if multimodal based on architecture
|
// Determine if multimodal based on architecture
|
||||||
final isMultimodal =
|
final isMultimodal =
|
||||||
modality?.contains('image') == true ||
|
cachedIsMultimodal ??
|
||||||
inputModalities?.contains('image') == true;
|
(modality?.contains('image') == true ||
|
||||||
|
inputModalities?.contains('image') == true);
|
||||||
|
|
||||||
// Extract supported parameters robustly (top-level or nested under provider keys)
|
// Extract supported parameters robustly (top-level or nested under provider keys)
|
||||||
List? supportedParams =
|
List? supportedParams =
|
||||||
@@ -63,7 +76,8 @@ sealed class Model with _$Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine streaming support from supported parameters if known
|
// 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<String> if present
|
// Convert supported parameters to List<String> if present
|
||||||
final supportedParamsList = supportedParams
|
final supportedParamsList = supportedParams
|
||||||
@@ -154,4 +168,22 @@ sealed class Model with _$Model {
|
|||||||
toolIds: toolIds,
|
toolIds: toolIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final data = <String, dynamic>{
|
||||||
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
lib/core/models/socket_transport_availability.dart
Normal file
23
lib/core/models/socket_transport_availability.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class SocketTransportAvailability {
|
||||||
|
const SocketTransportAvailability({
|
||||||
|
required this.allowPolling,
|
||||||
|
required this.allowWebsocketOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool allowPolling;
|
||||||
|
final bool allowWebsocketOnly;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'allowPolling': allowPolling,
|
||||||
|
'allowWebsocketOnly': allowWebsocketOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SocketTransportAvailability.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SocketTransportAvailability(
|
||||||
|
allowPolling: json['allowPolling'] == true,
|
||||||
|
allowWebsocketOnly: json['allowWebsocketOnly'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,14 @@ sealed class Tool with _$Tool {
|
|||||||
meta: json['meta'] as Map<String, dynamic>?,
|
meta: json['meta'] as Map<String, dynamic>?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'user_id': userId,
|
||||||
|
'meta': meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,16 @@ sealed class User with _$User {
|
|||||||
isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true,
|
isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'name': name,
|
||||||
|
'profile_image_url': profileImage,
|
||||||
|
'role': role,
|
||||||
|
'is_active': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ final class HiveStoreKeys {
|
|||||||
|
|
||||||
// Cache entries
|
// Cache entries
|
||||||
static const String localConversations = 'local_conversations';
|
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 localFolders = 'local_folders';
|
||||||
static const String attachmentQueueEntries = 'attachment_queue_entries';
|
static const String attachmentQueueEntries = 'attachment_queue_entries';
|
||||||
static const String taskQueue = 'outbound_task_queue_v1';
|
static const String taskQueue = 'outbound_task_queue_v1';
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import '../persistence/persistence_providers.dart';
|
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../auth/auth_state_manager.dart';
|
import '../auth/auth_state_manager.dart';
|
||||||
import '../../features/auth/providers/unified_auth_providers.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/tweakcn_themes.dart';
|
||||||
import '../../shared/theme/app_theme.dart';
|
import '../../shared/theme/app_theme.dart';
|
||||||
import '../../features/tools/providers/tools_providers.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';
|
part 'app_providers.g.dart';
|
||||||
|
|
||||||
// Storage providers
|
|
||||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
|
||||||
// Single, shared instance with explicit platform options
|
|
||||||
return const FlutterSecureStorage(
|
|
||||||
aOptions: AndroidOptions(
|
|
||||||
encryptedSharedPreferences: true,
|
|
||||||
sharedPreferencesName: 'conduit_secure_prefs',
|
|
||||||
preferencesKeyPrefix: 'conduit_',
|
|
||||||
// Avoid auto-wipe on transient errors; we handle errors in code
|
|
||||||
resetOnError: false,
|
|
||||||
),
|
|
||||||
iOptions: IOSOptions(
|
|
||||||
accountName: 'conduit_secure_storage',
|
|
||||||
synchronizable: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimized storage service provider
|
|
||||||
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return OptimizedStorageService(
|
|
||||||
secureStorage: ref.watch(secureStorageProvider),
|
|
||||||
boxes: ref.watch(hiveBoxesProvider),
|
|
||||||
workerManager: ref.watch(workerManagerProvider),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Theme provider
|
// Theme provider
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class AppThemeMode extends _$AppThemeMode {
|
class AppThemeMode extends _$AppThemeMode {
|
||||||
@@ -175,7 +148,37 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
|
@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);
|
final api = ref.watch(apiServiceProvider);
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -205,21 +208,22 @@ final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
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<SocketTransportAvailability>((
|
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||||
|
// Watch async backend config for proper invalidation
|
||||||
final backendConfigAsync = ref.watch(backendConfigProvider);
|
final backendConfigAsync = ref.watch(backendConfigProvider);
|
||||||
final config = backendConfigAsync.maybeWhen(
|
final config = backendConfigAsync.maybeWhen(
|
||||||
data: (value) => value,
|
data: (value) => value,
|
||||||
@@ -227,30 +231,16 @@ final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return const SocketTransportAvailability(
|
// Return cached value or defaults when config not available
|
||||||
allowPolling: true,
|
return storage.getLocalTransportOptionsSync() ??
|
||||||
allowWebsocketOnly: true,
|
const SocketTransportAvailability(
|
||||||
);
|
allowPolling: true,
|
||||||
|
allowWebsocketOnly: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.websocketOnly) {
|
// Determine transport availability from backend config
|
||||||
return const SocketTransportAvailability(
|
return _resolveTransportAvailability(config);
|
||||||
allowPolling: false,
|
|
||||||
allowWebsocketOnly: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.pollingOnly) {
|
|
||||||
return const SocketTransportAvailability(
|
|
||||||
allowPolling: true,
|
|
||||||
allowWebsocketOnly: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SocketTransportAvailability(
|
|
||||||
allowPolling: true,
|
|
||||||
allowWebsocketOnly: true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Service provider with unified auth integration
|
// API Service provider with unified auth integration
|
||||||
@@ -551,52 +541,146 @@ final refreshAuthStateProvider = Provider<void>((ref) {
|
|||||||
|
|
||||||
// Model providers
|
// Model providers
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Future<List<Model>> models(Ref ref) async {
|
class Models extends _$Models {
|
||||||
// Reviewer mode returns mock models
|
@override
|
||||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
Future<List<Model>> build() async {
|
||||||
if (reviewerMode) {
|
// Reviewer mode returns mock models
|
||||||
return [
|
if (ref.watch(reviewerModeProvider)) {
|
||||||
const Model(
|
return _demoModels();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
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;
|
||||||
|
}
|
||||||
|
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 <Model>[]);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final fresh = await _load(api);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
@@ -1243,6 +1327,7 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
|
|
||||||
// Initialize the settings watcher (side-effect only)
|
// Initialize the settings watcher (side-effect only)
|
||||||
ref.read(_settingsWatcherProvider);
|
ref.read(_settingsWatcherProvider);
|
||||||
|
final storage = ref.read(optimizedStorageServiceProvider);
|
||||||
// Read settings without subscribing to rebuilds to avoid watch/await hazards
|
// Read settings without subscribing to rebuilds to avoid watch/await hazards
|
||||||
final reviewerMode = ref.read(reviewerModeProvider);
|
final reviewerMode = ref.read(reviewerModeProvider);
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
@@ -1287,6 +1372,20 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
DebugLogger.log('api-available', scope: 'models/default');
|
DebugLogger.log('api-available', scope: 'models/default');
|
||||||
|
|
||||||
try {
|
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
|
// Respect manual selection if present
|
||||||
if (ref.read(isManualModelSelectionProvider)) {
|
if (ref.read(isManualModelSelectionProvider)) {
|
||||||
final current = ref.read(selectedModelProvider);
|
final current = ref.read(selectedModelProvider);
|
||||||
@@ -1298,18 +1397,46 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
final storedDefaultId = await SettingsService.getDefaultModel();
|
final storedDefaultId = await SettingsService.getDefaultModel();
|
||||||
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
|
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
|
||||||
if (!ref.read(isManualModelSelectionProvider)) {
|
if (!ref.read(isManualModelSelectionProvider)) {
|
||||||
final placeholder = Model(
|
final cachedMatch = await selectCachedModel(storage, storedDefaultId);
|
||||||
id: storedDefaultId,
|
if (cachedMatch != null) {
|
||||||
name: storedDefaultId,
|
ref.read(selectedModelProvider.notifier).set(cachedMatch);
|
||||||
supportsStreaming: true,
|
unawaited(
|
||||||
);
|
storage.saveLocalDefaultModel(cachedMatch).onError((
|
||||||
ref.read(selectedModelProvider.notifier).set(placeholder);
|
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
|
// Reconcile against real models in background
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
try {
|
try {
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
final models = await ref.read(modelsProvider.future);
|
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;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
Model? resolved;
|
Model? resolved;
|
||||||
@@ -1326,6 +1453,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).set(resolved);
|
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(
|
DebugLogger.log(
|
||||||
'reconcile',
|
'reconcile',
|
||||||
scope: 'models/default',
|
scope: 'models/default',
|
||||||
@@ -1355,6 +1492,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
);
|
);
|
||||||
ref.read(selectedModelProvider.notifier).set(placeholder);
|
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
|
// Reconcile against real models in background
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
@@ -1377,6 +1524,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).set(resolved);
|
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(
|
DebugLogger.log(
|
||||||
'reconcile',
|
'reconcile',
|
||||||
scope: 'models/default',
|
scope: 'models/default',
|
||||||
@@ -1410,6 +1567,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
|||||||
final selectedModel = models.first;
|
final selectedModel = models.first;
|
||||||
if (!ref.read(isManualModelSelectionProvider)) {
|
if (!ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).set(selectedModel);
|
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(
|
DebugLogger.log(
|
||||||
'fallback-selected',
|
'fallback-selected',
|
||||||
scope: 'models/default',
|
scope: 'models/default',
|
||||||
@@ -2158,3 +2325,66 @@ Future<List<Map<String, dynamic>>> imageModels(Ref ref) async {
|
|||||||
return [];
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
35
lib/core/providers/storage_providers.dart
Normal file
35
lib/core/providers/storage_providers.dart
Normal file
@@ -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<FlutterSecureStorage>((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<OptimizedStorageService>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return OptimizedStorageService(
|
||||||
|
secureStorage: ref.watch(secureStorageProvider),
|
||||||
|
boxes: ref.watch(hiveBoxesProvider),
|
||||||
|
workerManager: ref.watch(workerManagerProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
121
lib/core/services/cache_manager.dart
Normal file
121
lib/core/services/cache_manager.dart
Normal file
@@ -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<String, _CacheRecord> _entries = {};
|
||||||
|
|
||||||
|
/// Reads a cached value and returns whether the lookup was a hit.
|
||||||
|
({bool hit, T? value}) lookup<T>(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<T>(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<String, dynamic> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
import '../models/backend_config.dart';
|
||||||
import '../models/conversation.dart';
|
import '../models/conversation.dart';
|
||||||
import '../models/folder.dart';
|
import '../models/folder.dart';
|
||||||
|
import '../models/model.dart';
|
||||||
import '../models/server_config.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/hive_boxes.dart';
|
||||||
import '../persistence/persistence_keys.dart';
|
import '../persistence/persistence_keys.dart';
|
||||||
import '../utils/debug_logger.dart';
|
import '../utils/debug_logger.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
import 'secure_credential_storage.dart';
|
import 'secure_credential_storage.dart';
|
||||||
import 'worker_manager.dart';
|
import 'worker_manager.dart';
|
||||||
|
|
||||||
@@ -34,6 +40,7 @@ class OptimizedStorageService {
|
|||||||
final Box<dynamic> _metadataBox;
|
final Box<dynamic> _metadataBox;
|
||||||
final SecureCredentialStorage _secureCredentialStorage;
|
final SecureCredentialStorage _secureCredentialStorage;
|
||||||
final WorkerManager _workerManager;
|
final WorkerManager _workerManager;
|
||||||
|
final CacheManager _cacheManager = CacheManager(maxEntries: 64);
|
||||||
|
|
||||||
static const String _authTokenKey = 'auth_token_v3';
|
static const String _authTokenKey = 'auth_token_v3';
|
||||||
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
|
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
|
||||||
@@ -41,22 +48,25 @@ class OptimizedStorageService {
|
|||||||
static const String _themePaletteKey = PreferenceKeys.themePalette;
|
static const String _themePaletteKey = PreferenceKeys.themePalette;
|
||||||
static const String _localeCodeKey = PreferenceKeys.localeCode;
|
static const String _localeCodeKey = PreferenceKeys.localeCode;
|
||||||
static const String _localConversationsKey = HiveStoreKeys.localConversations;
|
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 _localFoldersKey = HiveStoreKeys.localFolders;
|
||||||
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
|
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
|
||||||
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
|
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
|
||||||
|
|
||||||
final Map<String, dynamic> _cache = {};
|
|
||||||
final Map<String, DateTime> _cacheTimestamps = {};
|
|
||||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth token APIs (secure storage + in-memory cache)
|
// Auth token APIs (secure storage + in-memory cache)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<void> saveAuthToken(String token) async {
|
Future<void> saveAuthToken(String token) async {
|
||||||
try {
|
try {
|
||||||
await _secureCredentialStorage.saveAuthToken(token);
|
await _secureCredentialStorage.saveAuthToken(token);
|
||||||
_cache[_authTokenKey] = token;
|
_cacheManager.write(_authTokenKey, token);
|
||||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Auth token saved and cached',
|
'Auth token saved and cached',
|
||||||
scope: 'storage/optimized',
|
scope: 'storage/optimized',
|
||||||
@@ -71,19 +81,17 @@ class OptimizedStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getAuthToken() async {
|
Future<String?> getAuthToken() async {
|
||||||
if (_isCacheValid(_authTokenKey)) {
|
final (hit: hasCachedToken, value: cachedToken) = _cacheManager
|
||||||
final cached = _cache[_authTokenKey] as String?;
|
.lookup<String>(_authTokenKey);
|
||||||
if (cached != null) {
|
if (hasCachedToken) {
|
||||||
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
|
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
|
||||||
return cached;
|
return cachedToken;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await _secureCredentialStorage.getAuthToken();
|
final token = await _secureCredentialStorage.getAuthToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
_cache[_authTokenKey] = token;
|
_cacheManager.write(_authTokenKey, token);
|
||||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -98,8 +106,7 @@ class OptimizedStorageService {
|
|||||||
Future<void> deleteAuthToken() async {
|
Future<void> deleteAuthToken() async {
|
||||||
try {
|
try {
|
||||||
await _secureCredentialStorage.deleteAuthToken();
|
await _secureCredentialStorage.deleteAuthToken();
|
||||||
_cache.remove(_authTokenKey);
|
_cacheManager.invalidate(_authTokenKey);
|
||||||
_cacheTimestamps.remove(_authTokenKey);
|
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Auth token deleted and cache cleared',
|
'Auth token deleted and cache cleared',
|
||||||
scope: 'storage/optimized',
|
scope: 'storage/optimized',
|
||||||
@@ -129,8 +136,7 @@ class OptimizedStorageService {
|
|||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
|
|
||||||
_cache['has_credentials'] = true;
|
_cacheManager.write('has_credentials', true);
|
||||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
|
||||||
|
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Credentials saved via optimized storage',
|
'Credentials saved via optimized storage',
|
||||||
@@ -148,8 +154,7 @@ class OptimizedStorageService {
|
|||||||
Future<Map<String, String>?> getSavedCredentials() async {
|
Future<Map<String, String>?> getSavedCredentials() async {
|
||||||
try {
|
try {
|
||||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||||
_cache['has_credentials'] = credentials != null;
|
_cacheManager.write('has_credentials', credentials != null);
|
||||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
|
||||||
return credentials;
|
return credentials;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
@@ -163,8 +168,7 @@ class OptimizedStorageService {
|
|||||||
Future<void> deleteSavedCredentials() async {
|
Future<void> deleteSavedCredentials() async {
|
||||||
try {
|
try {
|
||||||
await _secureCredentialStorage.deleteSavedCredentials();
|
await _secureCredentialStorage.deleteSavedCredentials();
|
||||||
_cache.remove('has_credentials');
|
_cacheManager.invalidate('has_credentials');
|
||||||
_cacheTimestamps.remove('has_credentials');
|
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Credentials deleted via optimized storage',
|
'Credentials deleted via optimized storage',
|
||||||
scope: 'storage/optimized',
|
scope: 'storage/optimized',
|
||||||
@@ -180,8 +184,10 @@ class OptimizedStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> hasCredentials() async {
|
Future<bool> hasCredentials() async {
|
||||||
if (_isCacheValid('has_credentials')) {
|
final (hit: hasCachedValue, value: hasCredentials) = _cacheManager
|
||||||
return _cache['has_credentials'] == true;
|
.lookup<bool>('has_credentials');
|
||||||
|
if (hasCachedValue) {
|
||||||
|
return hasCredentials == true;
|
||||||
}
|
}
|
||||||
final credentials = await getSavedCredentials();
|
final credentials = await getSavedCredentials();
|
||||||
return credentials != null;
|
return credentials != null;
|
||||||
@@ -194,8 +200,7 @@ class OptimizedStorageService {
|
|||||||
try {
|
try {
|
||||||
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
|
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
|
||||||
await _secureCredentialStorage.saveServerConfigs(jsonString);
|
await _secureCredentialStorage.saveServerConfigs(jsonString);
|
||||||
_cache['server_config_count'] = configs.length;
|
_cacheManager.write('server_config_count', configs.length);
|
||||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Server configs saved (${configs.length} entries)',
|
'Server configs saved (${configs.length} entries)',
|
||||||
scope: 'storage/optimized',
|
scope: 'storage/optimized',
|
||||||
@@ -213,8 +218,7 @@ class OptimizedStorageService {
|
|||||||
try {
|
try {
|
||||||
final jsonString = await _secureCredentialStorage.getServerConfigs();
|
final jsonString = await _secureCredentialStorage.getServerConfigs();
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
_cache['server_config_count'] = 0;
|
_cacheManager.write('server_config_count', 0);
|
||||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +226,7 @@ class OptimizedStorageService {
|
|||||||
final configs = decoded
|
final configs = decoded
|
||||||
.map((item) => ServerConfig.fromJson(item))
|
.map((item) => ServerConfig.fromJson(item))
|
||||||
.toList();
|
.toList();
|
||||||
_cache['server_config_count'] = configs.length;
|
_cacheManager.write('server_config_count', configs.length);
|
||||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
|
||||||
return configs;
|
return configs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
@@ -240,17 +243,18 @@ class OptimizedStorageService {
|
|||||||
} else {
|
} else {
|
||||||
await _preferencesBox.delete(_activeServerIdKey);
|
await _preferencesBox.delete(_activeServerIdKey);
|
||||||
}
|
}
|
||||||
_cache[_activeServerIdKey] = serverId;
|
_cacheManager.write(_activeServerIdKey, serverId);
|
||||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getActiveServerId() async {
|
Future<String?> getActiveServerId() async {
|
||||||
if (_isCacheValid(_activeServerIdKey)) {
|
final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup<String>(
|
||||||
return _cache[_activeServerIdKey] as String?;
|
_activeServerIdKey,
|
||||||
|
);
|
||||||
|
if (hasCachedId) {
|
||||||
|
return cachedId;
|
||||||
}
|
}
|
||||||
final serverId = _preferencesBox.get(_activeServerIdKey) as String?;
|
final serverId = _preferencesBox.get(_activeServerIdKey) as String?;
|
||||||
_cache[_activeServerIdKey] = serverId;
|
_cacheManager.write(_activeServerIdKey, serverId);
|
||||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
|
||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +396,378 @@ class OptimizedStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<User?> 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<String, dynamic>) {
|
||||||
|
return User.fromJson(decoded);
|
||||||
|
}
|
||||||
|
} else if (stored is Map<String, dynamic>) {
|
||||||
|
return User.fromJson(stored);
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'Failed to retrieve local user',
|
||||||
|
scope: 'storage/optimized',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String?> 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<void> 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<BackendConfig?> 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<String, dynamic>) {
|
||||||
|
return BackendConfig.fromJson(decoded);
|
||||||
|
}
|
||||||
|
} else if (payload is Map) {
|
||||||
|
return BackendConfig.fromJson(Map<String, dynamic>.from(payload));
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'Failed to retrieve local backend config',
|
||||||
|
scope: 'storage/optimized',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<SocketTransportAvailability?> 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<String, dynamic>) {
|
||||||
|
return _transportFromJson(decoded);
|
||||||
|
}
|
||||||
|
} else if (payload is Map) {
|
||||||
|
return _transportFromJson(Map<String, dynamic>.from(payload));
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'Failed to retrieve local transport options',
|
||||||
|
scope: 'storage/optimized',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic>) {
|
||||||
|
return _transportFromJson(decoded);
|
||||||
|
}
|
||||||
|
} else if (payload is Map) {
|
||||||
|
return _transportFromJson(Map<String, dynamic>.from(payload));
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'Failed to retrieve local transport options sync',
|
||||||
|
scope: 'storage/optimized',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Model>> 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<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
||||||
|
_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<void> saveLocalModels(List<Model> models) async {
|
||||||
|
try {
|
||||||
|
final jsonReady = models.map((model) => model.toJson()).toList();
|
||||||
|
final serialized = await _workerManager
|
||||||
|
.schedule<Map<String, dynamic>, 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<List<Tool>> 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<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
||||||
|
_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<void> saveLocalTools(List<Tool> tools) async {
|
||||||
|
try {
|
||||||
|
final jsonReady = tools.map((tool) => tool.toJson()).toList();
|
||||||
|
final serialized = await _workerManager
|
||||||
|
.schedule<Map<String, dynamic>, 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<Model?> 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<String, dynamic>) {
|
||||||
|
parsed = Model.fromJson(decoded);
|
||||||
|
}
|
||||||
|
} else if (payload is Map) {
|
||||||
|
parsed = Model.fromJson(Map<String, dynamic>.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<void> 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
|
// Batch operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -402,18 +778,19 @@ class OptimizedStorageService {
|
|||||||
deleteAuthToken(),
|
deleteAuthToken(),
|
||||||
deleteSavedCredentials(),
|
deleteSavedCredentials(),
|
||||||
_preferencesBox.delete(_activeServerIdKey),
|
_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)
|
// Clear server configurations (which include custom headers)
|
||||||
_secureCredentialStorage.clearAll(),
|
_secureCredentialStorage.clearAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
_cache.removeWhere(
|
_cacheManager.invalidateMatching(
|
||||||
(key, _) =>
|
(key) =>
|
||||||
key.contains('auth') ||
|
|
||||||
key.contains('credentials') ||
|
|
||||||
key.contains('server'),
|
|
||||||
);
|
|
||||||
_cacheTimestamps.removeWhere(
|
|
||||||
(key, _) =>
|
|
||||||
key.contains('auth') ||
|
key.contains('auth') ||
|
||||||
key.contains('credentials') ||
|
key.contains('credentials') ||
|
||||||
key.contains('server'),
|
key.contains('server'),
|
||||||
@@ -434,8 +811,7 @@ class OptimizedStorageService {
|
|||||||
_attachmentQueueBox.clear(),
|
_attachmentQueueBox.clear(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
_cache.clear();
|
_cacheManager.clear();
|
||||||
_cacheTimestamps.clear();
|
|
||||||
|
|
||||||
// Preserve migration metadata
|
// Preserve migration metadata
|
||||||
final migrationVersion =
|
final migrationVersion =
|
||||||
@@ -462,22 +838,53 @@ class OptimizedStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cache helpers
|
// Server scoping helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
bool _isCacheValid(String key) {
|
(Object?, String?) _unwrapServerScoped(Object? stored) {
|
||||||
final timestamp = _cacheTimestamps[key];
|
if (stored is Map && stored.containsKey('data')) {
|
||||||
if (timestamp == null) {
|
final serverId = stored['serverId'];
|
||||||
return false;
|
return (stored['data'], serverId is String ? serverId : null);
|
||||||
}
|
}
|
||||||
return DateTime.now().difference(timestamp) < _cacheTimeout;
|
return (stored, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _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<String>(
|
||||||
|
_activeServerIdKey,
|
||||||
|
);
|
||||||
|
if (hasCachedId) {
|
||||||
|
return cachedId;
|
||||||
|
}
|
||||||
|
return _preferencesBox.get(_activeServerIdKey) as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
void clearCache() {
|
void clearCache() {
|
||||||
_cache.clear();
|
_cacheManager.clear();
|
||||||
_cacheTimestamps.clear();
|
|
||||||
DebugLogger.log('Storage cache cleared', scope: 'storage/optimized');
|
DebugLogger.log('Storage cache cleared', scope: 'storage/optimized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SocketTransportAvailability? _transportFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return SocketTransportAvailability.fromJson(json);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Legacy migration hooks (no-op)
|
// Legacy migration hooks (no-op)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -500,13 +907,7 @@ class OptimizedStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> getStorageStats() {
|
Map<String, dynamic> getStorageStats() {
|
||||||
return {
|
return _cacheManager.stats();
|
||||||
'cacheSize': _cache.length,
|
|
||||||
'cachedKeys': _cache.keys.toList(),
|
|
||||||
'lastAccess': _cacheTimestamps.entries
|
|
||||||
.map((entry) => '${entry.key}: ${entry.value}')
|
|
||||||
.toList(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import '../persistence/hive_bootstrap.dart';
|
||||||
import '../persistence/hive_boxes.dart';
|
import '../persistence/hive_boxes.dart';
|
||||||
import '../persistence/persistence_keys.dart';
|
import '../persistence/persistence_keys.dart';
|
||||||
import 'animation_service.dart';
|
import 'animation_service.dart';
|
||||||
@@ -126,47 +129,7 @@ class SettingsService {
|
|||||||
/// Load all settings
|
/// Load all settings
|
||||||
static Future<AppSettings> loadSettings() {
|
static Future<AppSettings> loadSettings() {
|
||||||
final box = _preferencesBox();
|
final box = _preferencesBox();
|
||||||
return Future.value(
|
return Future.value(_loadSettingsSync(box));
|
||||||
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<String>.from(
|
|
||||||
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save all settings
|
/// Save all settings
|
||||||
@@ -379,6 +342,40 @@ class SettingsService {
|
|||||||
// Ensure reasonable bounds
|
// Ensure reasonable bounds
|
||||||
return baseScale.clamp(0.8, 3.0);
|
return baseScale.clamp(0.8, 3.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static AppSettings _loadSettingsSync(Box<dynamic> 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<String>.from(
|
||||||
|
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
|
||||||
|
),
|
||||||
|
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
|
/// Sentinel class to detect when defaultModel parameter is not provided
|
||||||
@@ -562,23 +559,36 @@ bool _listEquals(List<String> a, List<String> b) {
|
|||||||
/// Provider for app settings
|
/// Provider for app settings
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class AppSettingsNotifier extends _$AppSettingsNotifier {
|
class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||||
bool _initialized = false;
|
Future<void>? _pendingLoad;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppSettings build() {
|
AppSettings build() {
|
||||||
if (!_initialized) {
|
if (Hive.isBoxOpen(HiveBoxNames.preferences)) {
|
||||||
_initialized = true;
|
final box = Hive.box<dynamic>(HiveBoxNames.preferences);
|
||||||
Future.microtask(_loadSettings);
|
return SettingsService._loadSettingsSync(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_pendingLoad ??= _hydrateFromHive();
|
||||||
return const AppSettings();
|
return const AppSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _hydrateFromHive() async {
|
||||||
final settings = await SettingsService.loadSettings();
|
try {
|
||||||
if (!ref.mounted) {
|
await HiveBootstrap.instance.ensureInitialized();
|
||||||
return;
|
if (!ref.mounted) return;
|
||||||
|
final box = Hive.box<dynamic>(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<void> setReduceMotion(bool value) async {
|
Future<void> setReduceMotion(bool value) async {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import '../../../core/utils/user_display_name.dart';
|
|||||||
import '../../../core/utils/model_icon_utils.dart';
|
import '../../../core/utils/model_icon_utils.dart';
|
||||||
import '../../auth/providers/unified_auth_providers.dart';
|
import '../../auth/providers/unified_auth_providers.dart';
|
||||||
import '../../../core/utils/android_assistant_handler.dart';
|
import '../../../core/utils/android_assistant_handler.dart';
|
||||||
|
|
||||||
import '../widgets/modern_chat_input.dart';
|
import '../widgets/modern_chat_input.dart';
|
||||||
import '../widgets/user_message_bubble.dart';
|
import '../widgets/user_message_bubble.dart';
|
||||||
import '../widgets/assistant_message_widget.dart' as assistant;
|
import '../widgets/assistant_message_widget.dart' as assistant;
|
||||||
@@ -83,6 +82,42 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
return fileSize <= (maxSizeMB * 1024 * 1024);
|
return fileSize <= (maxSizeMB * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Model?> _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() {
|
void startNewChat() {
|
||||||
// Clear current conversation
|
// Clear current conversation
|
||||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||||
@@ -110,6 +145,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
return;
|
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');
|
DebugLogger.log('auto-select-start', scope: 'chat/model');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,15 +1,59 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import 'package:conduit/core/models/tool.dart';
|
import 'package:conduit/core/models/tool.dart';
|
||||||
|
import 'package:conduit/core/providers/storage_providers.dart';
|
||||||
import 'package:conduit/core/services/tools_service.dart';
|
import 'package:conduit/core/services/tools_service.dart';
|
||||||
|
|
||||||
part 'tools_providers.g.dart';
|
part 'tools_providers.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Future<List<Tool>> toolsList(Ref ref) async {
|
class ToolsList extends _$ToolsList {
|
||||||
final toolsService = ref.watch(toolsServiceProvider);
|
@override
|
||||||
if (toolsService == null) return [];
|
Future<List<Tool>> build() async {
|
||||||
return await toolsService.getTools();
|
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<void> 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<List<Tool>> _fetchAndPersist(ToolsService service) async {
|
||||||
|
final tools = await service.getTools();
|
||||||
|
final storage = ref.read(optimizedStorageServiceProvider);
|
||||||
|
await storage.saveLocalTools(tools);
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
|
|||||||
Reference in New Issue
Block a user