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

1803 lines
54 KiB
Dart
Raw Normal View History

2025-09-28 23:18:24 +05:30
import 'dart:async';
2025-08-10 01:20:45 +05:30
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
2025-09-28 23:18:24 +05:30
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../persistence/persistence_providers.dart';
2025-08-10 01:20:45 +05:30
import '../services/api_service.dart';
import '../auth/auth_state_manager.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../services/attachment_upload_queue.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/model.dart';
import '../models/conversation.dart';
2025-08-17 16:11:19 +05:30
import '../models/chat_message.dart';
2025-08-17 00:05:30 +05:30
import '../models/folder.dart';
2025-08-10 01:20:45 +05:30
import '../models/user_settings.dart';
import '../models/file_info.dart';
import '../models/knowledge_base.dart';
import '../services/settings_service.dart';
2025-08-10 01:20:45 +05:30
import '../services/optimized_storage_service.dart';
2025-08-31 14:02:44 +05:30
import '../services/socket_service.dart';
2025-08-20 22:15:26 +05:30
import '../utils/debug_logger.dart';
2025-09-29 00:22:12 +05:30
import '../models/socket_event.dart';
import '../../shared/theme/color_palettes.dart';
import '../../shared/theme/app_theme.dart';
import '../../features/tools/providers/tools_providers.dart';
2025-08-10 01:20:45 +05:30
2025-09-28 23:18:24 +05:30
part 'app_providers.g.dart';
2025-08-10 01:20:45 +05:30
// 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,
),
);
2025-08-10 01:20:45 +05:30
});
// Optimized storage service provider
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
ref,
) {
return OptimizedStorageService(
secureStorage: ref.watch(secureStorageProvider),
boxes: ref.watch(hiveBoxesProvider),
2025-08-10 01:20:45 +05:30
);
});
// Theme provider
@Riverpod(keepAlive: true)
class AppThemeMode extends _$AppThemeMode {
2025-09-21 22:31:44 +05:30
late final OptimizedStorageService _storage;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
@override
ThemeMode build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final storedMode = _storage.getThemeMode();
if (storedMode != null) {
return ThemeMode.values.firstWhere(
(e) => e.toString() == storedMode,
2025-08-10 01:20:45 +05:30
orElse: () => ThemeMode.system,
);
}
2025-09-21 22:31:44 +05:30
return ThemeMode.system;
2025-08-10 01:20:45 +05:30
}
void setTheme(ThemeMode mode) {
state = mode;
_storage.setThemeMode(mode.toString());
}
}
@Riverpod(keepAlive: true)
class AppThemePalette extends _$AppThemePalette {
late final OptimizedStorageService _storage;
@override
AppColorPalette build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final storedId = _storage.getThemePaletteId();
return AppColorPalettes.byId(storedId);
}
Future<void> setPalette(String paletteId) async {
final palette = AppColorPalettes.byId(paletteId);
state = palette;
await _storage.setThemePaletteId(palette.id);
}
}
@Riverpod(keepAlive: true)
class AppLightTheme extends _$AppLightTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.light(palette);
}
}
@Riverpod(keepAlive: true)
class AppDarkTheme extends _$AppDarkTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.dark(palette);
}
}
// Locale provider
@Riverpod(keepAlive: true)
class AppLocale extends _$AppLocale {
2025-09-21 22:31:44 +05:30
late final OptimizedStorageService _storage;
2025-09-21 22:31:44 +05:30
@override
Locale? build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final code = _storage.getLocaleCode();
if (code != null && code.isNotEmpty) {
2025-09-21 22:31:44 +05:30
return Locale(code);
}
2025-09-21 22:31:44 +05:30
return null; // system default
}
Future<void> setLocale(Locale? locale) async {
state = locale;
await _storage.setLocaleCode(locale?.languageCode);
}
}
2025-08-10 01:20:45 +05:30
// Server connection providers - optimized with caching
@Riverpod(keepAlive: true)
Future<List<ServerConfig>> serverConfigs(Ref ref) async {
2025-08-10 01:20:45 +05:30
final storage = ref.watch(optimizedStorageServiceProvider);
return storage.getServerConfigs();
}
2025-08-10 01:20:45 +05:30
@Riverpod(keepAlive: true)
Future<ServerConfig?> activeServer(Ref ref) async {
2025-08-10 01:20:45 +05:30
final storage = ref.watch(optimizedStorageServiceProvider);
final configs = await ref.watch(serverConfigsProvider.future);
final activeId = await storage.getActiveServerId();
if (activeId == null || configs.isEmpty) return null;
2025-09-23 00:58:58 +05:30
for (final config in configs) {
if (config.id == activeId) {
return config;
}
}
return null;
}
2025-08-10 01:20:45 +05:30
final serverConnectionStateProvider = Provider<bool>((ref) {
final activeServer = ref.watch(activeServerProvider);
return activeServer.maybeWhen(
data: (server) => server != null,
orElse: () => false,
);
});
// API Service provider with unified auth integration
final apiServiceProvider = Provider<ApiService?>((ref) {
// If reviewer mode is enabled, skip creating ApiService
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
return null;
}
final activeServer = ref.watch(activeServerProvider);
return activeServer.maybeWhen(
data: (server) {
if (server == null) return null;
final apiService = ApiService(
serverConfig: server,
authToken: null, // Will be set by auth state manager
);
// Keep callbacks in sync so interceptor can notify auth manager
apiService.setAuthCallbacks(
onAuthTokenInvalid: () {},
onTokenInvalidated: () async {
final authManager = ref.read(authStateManagerProvider.notifier);
await authManager.onTokenInvalidated();
},
);
// Set up callback for unified auth state manager
// (legacy properties kept during transition)
apiService.onTokenInvalidated = () async {
final authManager = ref.read(authStateManagerProvider.notifier);
await authManager.onTokenInvalidated();
};
// Keep legacy callback for backward compatibility during transition
apiService.onAuthTokenInvalid = () {
// This will be removed once migration is complete
2025-09-25 22:36:42 +05:30
DebugLogger.auth('legacy-token-callback', scope: 'auth/api');
2025-08-10 01:20:45 +05:30
};
return apiService;
},
orElse: () => null,
);
});
2025-08-31 14:02:44 +05:30
// Socket.IO service provider
2025-09-28 23:18:24 +05:30
@Riverpod(keepAlive: true)
class SocketServiceManager extends _$SocketServiceManager {
SocketService? _service;
ProviderSubscription<String?>? _tokenSubscription;
2025-08-31 14:02:44 +05:30
2025-09-28 23:18:24 +05:30
@override
FutureOr<SocketService?> build() async {
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
_disposeService();
return null;
}
2025-08-31 14:02:44 +05:30
2025-09-28 23:18:24 +05:30
final server = await ref.watch(activeServerProvider.future);
if (server == null) {
_disposeService();
return null;
}
final transportMode = ref.watch(
appSettingsProvider.select((settings) => settings.socketTransportMode),
);
final websocketOnly = transportMode == 'ws';
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
// Token updates are handled via the subscription below
final token = ref.read(authTokenProvider3);
2025-09-28 23:18:24 +05:30
final requiresNewService =
_service == null ||
_service!.serverConfig.id != server.id ||
_service!.websocketOnly != websocketOnly;
if (requiresNewService) {
_disposeService();
_service = SocketService(
serverConfig: server,
authToken: token,
2025-09-28 23:18:24 +05:30
websocketOnly: websocketOnly,
);
2025-09-28 23:18:24 +05:30
_scheduleConnect(_service!);
} else {
_service!.updateAuthToken(token);
}
_tokenSubscription ??= ref.listen<String?>(authTokenProvider3, (
previous,
next,
) {
_service?.updateAuthToken(next);
});
ref.onDispose(() {
_tokenSubscription?.close();
_tokenSubscription = null;
_disposeService();
});
return _service;
}
void _scheduleConnect(SocketService service) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 150));
if (!ref.mounted) return;
try {
unawaited(service.connect());
} catch (_) {}
});
}
void _disposeService() {
if (_service == null) return;
try {
_service!.dispose();
} catch (_) {}
_service = null;
}
}
final socketServiceProvider = Provider<SocketService?>((ref) {
final asyncService = ref.watch(socketServiceManagerProvider);
return asyncService.maybeWhen(data: (service) => service, orElse: () => null);
2025-08-31 14:02:44 +05:30
});
2025-09-28 23:18:24 +05:30
enum SocketConnectionState { disconnected, connecting, connected }
@Riverpod(keepAlive: true)
class SocketConnectionStream extends _$SocketConnectionStream {
StreamController<SocketConnectionState>? _controller;
ProviderSubscription<AsyncValue<SocketService?>>? _serviceSubscription;
2025-09-29 00:22:12 +05:30
VoidCallback? _cancelConnectListener;
VoidCallback? _cancelDisconnectListener;
SocketConnectionState _latestState = SocketConnectionState.connecting;
2025-09-28 23:18:24 +05:30
@override
Stream<SocketConnectionState> build() {
2025-09-29 00:22:12 +05:30
final controller = StreamController<SocketConnectionState>.broadcast(
sync: true,
);
controller
..onListen = _primeState
..onCancel = _maybeNotifyDisconnected;
2025-09-28 23:18:24 +05:30
_controller = controller;
2025-09-29 00:22:12 +05:30
final initialService = ref
.watch(socketServiceManagerProvider)
.maybeWhen(data: (service) => service, orElse: () => null);
_handleServiceChange(initialService);
2025-09-28 23:18:24 +05:30
_serviceSubscription = ref.listen<AsyncValue<SocketService?>>(
socketServiceManagerProvider,
2025-09-29 00:22:12 +05:30
(_, next) => _handleServiceChange(
next.maybeWhen(data: (service) => service, orElse: () => null),
),
2025-09-28 23:18:24 +05:30
);
ref.onDispose(() {
_serviceSubscription?.close();
_serviceSubscription = null;
_unbindSocket();
_controller?.close();
_controller = null;
});
return controller.stream;
}
2025-09-29 00:22:12 +05:30
/// Publishes a disconnected state when the final listener cancels.
void _maybeNotifyDisconnected() {
try {
_controller?.add(SocketConnectionState.disconnected);
_latestState = SocketConnectionState.disconnected;
} catch (_) {}
}
/// Replays the cached state to new listeners.
void _primeState() {
try {
_controller?.add(_latestState);
} catch (_) {}
}
void _handleServiceChange(SocketService? service) {
if (service == null) {
_unbindSocket();
_emit(SocketConnectionState.connecting);
2025-09-29 00:22:12 +05:30
return;
}
_emit(
service.isConnected
? SocketConnectionState.connected
: SocketConnectionState.connecting,
);
_bindSocket(service);
}
2025-09-28 23:18:24 +05:30
void _bindSocket(SocketService service) {
_unbindSocket();
void handleConnect(dynamic _) {
2025-09-29 00:22:12 +05:30
_emit(SocketConnectionState.connected);
2025-09-28 23:18:24 +05:30
}
void handleDisconnect(dynamic _) {
2025-09-29 00:22:12 +05:30
_emit(SocketConnectionState.disconnected);
2025-09-28 23:18:24 +05:30
}
service.socket?.on('connect', handleConnect);
service.socket?.on('disconnect', handleDisconnect);
_cancelConnectListener = () {
service.socket?.off('connect', handleConnect);
};
_cancelDisconnectListener = () {
service.socket?.off('disconnect', handleDisconnect);
};
}
2025-09-29 00:22:12 +05:30
void _emit(SocketConnectionState next) {
if (_latestState == next) {
return;
}
_latestState = next;
try {
_controller?.add(next);
} catch (_) {}
}
2025-09-28 23:18:24 +05:30
void _unbindSocket() {
_cancelConnectListener?.call();
_cancelDisconnectListener?.call();
_cancelConnectListener = null;
_cancelDisconnectListener = null;
}
}
2025-09-29 00:22:12 +05:30
@Riverpod(keepAlive: true)
class ConversationDeltaStream extends _$ConversationDeltaStream {
StreamController<ConversationDelta>? _controller;
ProviderSubscription<AsyncValue<SocketService?>>? _serviceSubscription;
SocketEventSubscription? _socketSubscription;
@override
Stream<ConversationDelta> build(ConversationDeltaRequest request) {
final controller = StreamController<ConversationDelta>.broadcast(
sync: true,
onCancel: _maybeTearDownSocket,
);
_controller = controller;
final initialService = ref
.watch(socketServiceManagerProvider)
.maybeWhen(data: (service) => service, orElse: () => null);
_bindSocket(initialService, request);
_serviceSubscription = ref.listen<AsyncValue<SocketService?>>(
socketServiceManagerProvider,
(_, next) => _bindSocket(
next.maybeWhen(data: (service) => service, orElse: () => null),
request,
),
);
ref.onDispose(() {
_serviceSubscription?.close();
_serviceSubscription = null;
_socketSubscription?.dispose();
_socketSubscription = null;
_controller?.close();
_controller = null;
});
2025-09-29 00:22:12 +05:30
return controller.stream;
}
2025-09-29 00:22:12 +05:30
void _bindSocket(SocketService? service, ConversationDeltaRequest request) {
_socketSubscription?.dispose();
_socketSubscription = null;
2025-09-29 00:22:12 +05:30
if (service == null) {
return;
}
switch (request.source) {
case ConversationDeltaSource.chat:
_socketSubscription = service.addChatEventHandler(
2025-09-29 00:22:12 +05:30
conversationId: request.conversationId,
sessionId: request.sessionId,
requireFocus: request.requireFocus,
handler: (event, ack) {
_controller?.add(
ConversationDelta.fromSocketEvent(
ConversationDeltaSource.chat,
event,
ack,
),
);
2025-09-29 00:22:12 +05:30
},
);
break;
case ConversationDeltaSource.channel:
_socketSubscription = service.addChannelEventHandler(
2025-09-29 00:22:12 +05:30
conversationId: request.conversationId,
sessionId: request.sessionId,
requireFocus: request.requireFocus,
handler: (event, ack) {
_controller?.add(
ConversationDelta.fromSocketEvent(
ConversationDeltaSource.channel,
event,
ack,
),
);
2025-09-29 00:22:12 +05:30
},
);
break;
}
}
void _maybeTearDownSocket() {
if (_controller?.hasListener == true) {
return;
}
_socketSubscription?.dispose();
_socketSubscription = null;
}
2025-10-01 00:35:56 +05:30
}
2025-09-29 00:22:12 +05:30
2025-08-10 01:20:45 +05:30
// Attachment upload queue provider
final attachmentUploadQueueProvider = Provider<AttachmentUploadQueue?>((ref) {
final api = ref.watch(apiServiceProvider);
if (api == null) return null;
final queue = AttachmentUploadQueue();
// Initialize once; subsequent calls are no-ops due to singleton
queue.initialize(
onUpload: (filePath, fileName) => api.uploadFile(filePath, fileName),
);
return queue;
});
// Auth providers
// Auth token integration with API service - using unified auth system
final apiTokenUpdaterProvider = Provider<void>((ref) {
void syncToken(ApiService? api, String? token) {
if (api == null) return;
api.updateAuthToken(token != null && token.isNotEmpty ? token : null);
final length = token?.length ?? 0;
DebugLogger.auth(
'token-updated',
scope: 'auth/api',
data: {'length': length},
);
}
syncToken(ref.read(apiServiceProvider), ref.read(authTokenProvider3));
ref.listen<ApiService?>(apiServiceProvider, (previous, next) {
syncToken(next, ref.read(authTokenProvider3));
});
2025-09-23 13:43:01 +05:30
ref.listen<String?>(authTokenProvider3, (previous, next) {
syncToken(ref.read(apiServiceProvider), next);
2025-08-10 01:20:45 +05:30
});
});
@Riverpod(keepAlive: true)
Future<User?> currentUser(Ref ref) async {
2025-08-10 01:20:45 +05:30
final api = ref.read(apiServiceProvider);
final isAuthenticated = ref.watch(isAuthenticatedProvider2);
if (api == null || !isAuthenticated) return null;
try {
return await api.getCurrentUser();
} catch (e) {
return null;
}
}
2025-08-10 01:20:45 +05:30
// Helper provider to force refresh auth state - now using unified system
final refreshAuthStateProvider = Provider<void>((ref) {
// This provider can be invalidated to force refresh the unified auth system
2025-08-29 12:58:56 +05:30
Future.microtask(() => ref.read(authActionsProvider).refresh());
2025-08-10 01:20:45 +05:30
return;
});
// Model providers
@Riverpod(keepAlive: true)
Future<List<Model>> models(Ref ref) async {
2025-08-10 01:20:45 +05:30
// Reviewer mode returns mock models
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
return [
const Model(
id: 'demo/gemma-2-mini',
name: 'Gemma 2 Mini (Demo)',
description: 'Demo model for reviewer mode',
isMultimodal: true,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
const Model(
id: 'demo/llama-3-8b',
name: 'Llama 3 8B (Demo)',
description: 'Fast text model for demo',
isMultimodal: false,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
2025-09-25 22:36:42 +05:30
DebugLogger.log('fetch-start', scope: 'models');
2025-08-10 01:20:45 +05:30
final models = await api.getModels();
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-ok',
scope: 'models',
data: {'count': models.length},
);
2025-08-10 01:20:45 +05:30
return models;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('fetch-failed', scope: 'models', error: e);
2025-08-10 01:20:45 +05:30
// If models endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('endpoint-403', scope: 'models');
2025-08-10 01:20:45 +05:30
}
return [];
}
}
2025-08-10 01:20:45 +05:30
@Riverpod(keepAlive: true)
class SelectedModel extends _$SelectedModel {
2025-09-21 22:31:44 +05:30
@override
Model? build() => null;
void set(Model? model) => state = model;
void clear() => state = null;
}
// Track if the current model selection is manual (user-selected) or automatic (default)
@Riverpod(keepAlive: true)
class IsManualModelSelection extends _$IsManualModelSelection {
2025-09-21 22:31:44 +05:30
@override
bool build() => false;
void set(bool value) => state = value;
}
2025-08-17 17:43:19 +05:30
// Listen for settings changes and reset manual selection when default model changes
// keepAlive to maintain listener throughout app lifecycle
2025-08-17 17:43:19 +05:30
final _settingsWatcherProvider = Provider<void>((ref) {
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
if (previous?.defaultModel != next.defaultModel) {
// Reset manual selection when default model changes
2025-09-21 22:31:44 +05:30
ref.read(isManualModelSelectionProvider.notifier).set(false);
2025-08-17 17:43:19 +05:30
}
});
});
// Auto-apply model-specific tools when model changes
final modelToolsAutoSelectionProvider = Provider<void>((ref) {
ref.listen<Model?>(selectedModelProvider, (previous, next) {
// Only react when the model actually changes
if (previous?.id == next?.id) return;
if (next == null) return;
// Load tools configured for this model
final modelToolIds = next.toolIds ?? [];
if (modelToolIds.isNotEmpty) {
// Filter to only include tools that are actually available
final toolsAsync = ref.read(toolsListProvider);
toolsAsync.whenData((availableTools) {
final validToolIds = modelToolIds
.where((id) => availableTools.any((t) => t.id == id))
.toList();
if (validToolIds.isNotEmpty) {
ref.read(selectedToolIdsProvider.notifier).set(validToolIds);
DebugLogger.log(
'auto-apply-tools',
scope: 'models/tools',
data: {'modelId': next.id, 'toolCount': validToolIds.length},
);
}
});
} else {
// Clear tools if model has no configured tools
ref.read(selectedToolIdsProvider.notifier).set([]);
}
});
});
2025-08-28 19:17:05 +05:30
// Auto-apply default model from settings when it changes (and not manually overridden)
// keepAlive to maintain listener throughout app lifecycle
2025-08-28 19:17:05 +05:30
final defaultModelAutoSelectionProvider = Provider<void>((ref) {
// Initialize the model tools auto-selection
ref.watch(modelToolsAutoSelectionProvider);
2025-08-28 19:17:05 +05:30
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
// Only react when default model value changes
if (previous?.defaultModel == next.defaultModel) return;
// Do not override manual selections
if (ref.read(isManualModelSelectionProvider)) return;
final desired = next.defaultModel;
if (desired == null || desired.isEmpty) return;
2025-09-01 18:49:43 +05:30
// Resolve the desired model against available models (by ID only)
2025-08-28 19:17:05 +05:30
Future(() async {
try {
// Prefer already-loaded models to avoid unnecessary fetches
List<Model> models;
final modelsAsync = ref.read(modelsProvider);
if (modelsAsync.hasValue) {
models = modelsAsync.value!;
} else {
models = await ref.read(modelsProvider.future);
}
Model? selected;
try {
2025-09-01 18:49:43 +05:30
selected = models.firstWhere((model) => model.id == desired);
} catch (_) {
selected = null;
}
2025-08-28 19:17:05 +05:30
// Fallback: keep current selection or pick first available
2025-09-16 18:15:44 +05:30
selected ??=
ref.read(selectedModelProvider) ??
2025-08-28 19:17:05 +05:30
(models.isNotEmpty ? models.first : null);
if (selected != null) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(selected);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'auto-apply',
scope: 'models/default',
data: {'name': selected.name},
2025-08-28 19:17:05 +05:30
);
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'auto-select-failed',
scope: 'models/default',
error: e,
2025-08-28 19:17:05 +05:30
);
}
});
});
});
2025-08-17 16:11:19 +05:30
// Cache timestamp for conversations to prevent rapid re-fetches
@Riverpod(keepAlive: true)
class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp {
2025-09-21 22:31:44 +05:30
@override
DateTime? build() => null;
void set(DateTime? timestamp) => state = timestamp;
}
2025-08-17 16:11:19 +05:30
/// Clears the in-memory timestamp cache and invalidates the conversations
/// provider so the next read forces a refetch. Optionally invalidates the
/// folders provider when folder metadata must stay in sync with conversations.
void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
ref.invalidate(conversationsProvider);
if (includeFolders) {
ref.invalidate(foldersProvider);
}
}
2025-08-17 16:11:19 +05:30
// Conversation providers - Now using correct OpenWebUI API with caching
// keepAlive to maintain cache during authenticated session
@Riverpod(keepAlive: true)
Future<List<Conversation>> conversations(Ref ref) async {
2025-09-28 21:33:49 +05:30
// Do not fetch protected data until authenticated. Use watch so we refetch
// when the auth state transitions in either direction.
final authed = ref.watch(isAuthenticatedProvider2);
2025-09-28 20:41:35 +05:30
if (!authed) {
DebugLogger.log('skip-unauthed', scope: 'conversations');
return [];
}
2025-08-17 16:11:19 +05:30
// Check if we have a recent cache (within 5 seconds)
final lastFetch = ref.read(_conversationsCacheTimestampProvider);
2025-08-17 16:11:19 +05:30
if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) {
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'cache-hit',
scope: 'conversations',
data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds},
);
2025-08-17 16:11:19 +05:30
// Note: Can't read our own provider here, would cause a cycle
// The caching is handled by Riverpod's built-in mechanism
}
2025-08-10 01:20:45 +05:30
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
// Provide a simple local demo conversation list
return [
Conversation(
id: 'demo-conv-1',
title: 'Welcome to Conduit (Demo)',
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
2025-08-17 16:11:19 +05:30
messages: [
ChatMessage(
id: 'demo-msg-1',
role: 'assistant',
content:
'**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.',
2025-08-17 16:11:19 +05:30
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
model: 'Gemma 2 Mini (Demo)',
isStreaming: false,
),
],
2025-08-10 01:20:45 +05:30
),
];
}
final api = ref.watch(apiServiceProvider);
if (api == null) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('api-missing', scope: 'conversations');
2025-08-10 01:20:45 +05:30
return [];
}
try {
2025-09-25 22:36:42 +05:30
DebugLogger.log('fetch-start', scope: 'conversations');
final conversations = await api
.getConversations(); // Fetch all conversations
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'fetch-ok',
scope: 'conversations',
data: {'count': conversations.length},
2025-08-10 01:20:45 +05:30
);
// Also fetch folder information and update conversations with folder IDs
try {
final foldersData = await api.getFolders();
2025-08-20 22:15:26 +05:30
DebugLogger.log(
2025-09-25 22:36:42 +05:30
'folders-fetched',
scope: 'conversations',
data: {'count': foldersData.length},
);
// Parse folder data into Folder objects
final folders = foldersData
.map((folderData) => Folder.fromJson(folderData))
.toList();
// Create a map of conversation ID to folder ID
final conversationToFolder = <String, String>{};
for (final folder in folders) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'folder',
scope: 'conversations/map',
data: {
'id': folder.id,
'name': folder.name,
'count': folder.conversationIds.length,
},
);
for (final conversationId in folder.conversationIds) {
conversationToFolder[conversationId] = folder.id;
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'map',
scope: 'conversations/map',
data: {'conversationId': conversationId, 'folderId': folder.id},
);
2025-08-17 16:11:19 +05:30
}
}
// Update conversations with folder IDs, preferring explicit folder_id from chat if present
// Use a map to ensure uniqueness by ID throughout the merge process
final conversationMap = <String, Conversation>{};
for (final conversation in conversations) {
// Prefer server-provided folderId on the chat itself
final explicitFolderId = conversation.folderId;
final mappedFolderId = conversationToFolder[conversation.id];
final folderIdToUse = explicitFolderId ?? mappedFolderId;
if (folderIdToUse != null) {
conversationMap[conversation.id] = conversation.copyWith(
folderId: folderIdToUse,
);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'update-folder',
scope: 'conversations/map',
data: {
'conversationId': conversation.id,
'folderId': folderIdToUse,
'explicit': explicitFolderId != null,
},
);
2025-08-17 00:05:30 +05:30
} else {
conversationMap[conversation.id] = conversation;
2025-08-17 00:05:30 +05:30
}
}
// Merge conversations that are in folders but missing from the main list
// Build a set of existing IDs from the fetched list
final existingIds = conversationMap.keys.toSet();
2025-08-17 16:11:19 +05:30
// Diagnostics: count how many folder-mapped IDs are missing from the main list
final missingInBase = conversationToFolder.keys
.where((id) => !existingIds.contains(id))
.toList();
if (missingInBase.isNotEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning(
'missing-in-base',
scope: 'conversations/map',
data: {
'count': missingInBase.length,
'preview': missingInBase.take(5).toList(),
},
);
} else {
2025-09-25 22:36:42 +05:30
DebugLogger.log('folders-synced', scope: 'conversations/map');
}
// Attempt to fetch missing conversations per-folder to construct accurate entries
// If per-folder fetch fails, fall back to creating minimal placeholder entries
final apiSvc = ref.read(apiServiceProvider);
for (final folder in folders) {
// Collect IDs in this folder that are missing
final missingIds = folder.conversationIds
.where((id) => !existingIds.contains(id))
.toList();
final hasKnownConversations = conversationMap.values.any(
(conversation) => conversation.folderId == folder.id,
);
final shouldFetchFolder =
apiSvc != null &&
(missingIds.isNotEmpty ||
(!hasKnownConversations && folder.conversationIds.isEmpty));
List<Conversation> folderConvs = const [];
if (shouldFetchFolder) {
try {
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
DebugLogger.log(
'folder-sync',
scope: 'conversations/map',
data: {
'folderId': folder.id,
'fetched': folderConvs.length,
'missingIds': missingIds.length,
},
);
} catch (e) {
DebugLogger.error(
'folder-fetch-failed',
scope: 'conversations/map',
error: e,
data: {'folderId': folder.id},
);
2025-08-17 16:11:19 +05:30
}
}
2025-08-17 16:11:19 +05:30
// Index fetched folder conversations for quick lookup
final fetchedMap = {for (final c in folderConvs) c.id: c};
for (final convId in missingIds) {
final fetched = fetchedMap[convId];
if (fetched != null) {
final toAdd = fetched.folderId == null
? fetched.copyWith(folderId: folder.id)
: fetched;
// Use map to prevent duplicates - this will overwrite if ID already exists
conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'add-missing',
scope: 'conversations/map',
data: {'conversationId': toAdd.id, 'folderId': folder.id},
);
} else {
// Create a minimal placeholder if not returned by folder API
final placeholder = Conversation(
id: convId,
title: 'Chat',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
messages: const [],
folderId: folder.id,
);
// Use map to prevent duplicates
conversationMap[convId] = placeholder;
existingIds.add(convId);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'add-placeholder',
scope: 'conversations/map',
data: {'conversationId': convId, 'folderId': folder.id},
);
2025-08-17 00:05:30 +05:30
}
}
if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) {
for (final conv in folderConvs) {
final toAdd = conv.folderId == null
? conv.copyWith(folderId: folder.id)
: conv;
conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id);
DebugLogger.log(
'add-folder-fetch',
scope: 'conversations/map',
data: {'conversationId': toAdd.id, 'folderId': folder.id},
);
}
2025-08-17 00:05:30 +05:30
}
}
2025-08-17 16:11:19 +05:30
// Convert map back to list - this ensures no duplicates by ID
final sortedConversations = conversationMap.values.toList();
2025-08-17 00:26:12 +05:30
// Sort conversations by updatedAt in descending order (most recent first)
2025-08-17 16:11:19 +05:30
sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'sort',
scope: 'conversations',
data: {'source': 'folder-sync'},
);
2025-08-17 16:11:19 +05:30
// Update cache timestamp
ref
.read(_conversationsCacheTimestampProvider.notifier)
.set(DateTime.now());
2025-08-17 16:11:19 +05:30
return sortedConversations;
2025-08-17 00:05:30 +05:30
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'folders-fetch-failed',
scope: 'conversations',
error: e,
);
2025-08-17 00:26:12 +05:30
// Sort conversations even when folder fetch fails
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'sort',
scope: 'conversations',
data: {'source': 'fallback'},
);
2025-08-17 16:11:19 +05:30
// Update cache timestamp
ref
.read(_conversationsCacheTimestampProvider.notifier)
.set(DateTime.now());
2025-08-17 00:05:30 +05:30
return conversations; // Return original conversations if folder fetch fails
}
2025-08-10 01:20:45 +05:30
} catch (e, stackTrace) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'fetch-failed',
scope: 'conversations',
error: e,
stackTrace: stackTrace,
);
2025-08-10 01:20:45 +05:30
// If conversations endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('endpoint-403', scope: 'conversations');
2025-08-10 01:20:45 +05:30
}
// Return empty list instead of re-throwing to allow app to continue functioning
return [];
}
}
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
final activeConversationProvider =
NotifierProvider<ActiveConversationNotifier, Conversation?>(
ActiveConversationNotifier.new,
);
class ActiveConversationNotifier extends Notifier<Conversation?> {
@override
Conversation? build() => null;
void set(Conversation? conversation) => state = conversation;
void clear() => state = null;
}
2025-08-10 01:20:45 +05:30
// Provider to load full conversation with messages
@riverpod
Future<Conversation> loadConversation(Ref ref, String conversationId) async {
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) {
throw Exception('No API service available');
}
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'load-start',
scope: 'conversation',
data: {'id': conversationId},
);
2025-08-10 01:20:45 +05:30
final fullConversation = await api.getConversation(conversationId);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'load-ok',
scope: 'conversation',
data: {'messages': fullConversation.messages.length},
2025-08-10 01:20:45 +05:30
);
return fullConversation;
}
2025-08-10 01:20:45 +05:30
// Provider to automatically load and set the default model from user settings or OpenWebUI
@Riverpod(keepAlive: true)
Future<Model?> defaultModel(Ref ref) async {
DebugLogger.log('provider-called', scope: 'models/default');
2025-08-28 18:54:06 +05:30
// Initialize the settings watcher (side-effect only)
ref.read(_settingsWatcherProvider);
// Read settings without subscribing to rebuilds to avoid watch/await hazards
final reviewerMode = ref.read(reviewerModeProvider);
if (reviewerMode) {
DebugLogger.log('reviewer-mode', scope: 'models/default');
2025-08-17 17:43:19 +05:30
// Check if a model is manually selected
final currentSelected = ref.read(selectedModelProvider);
2025-08-17 17:43:19 +05:30
final isManualSelection = ref.read(isManualModelSelectionProvider);
2025-08-20 22:15:26 +05:30
2025-08-17 17:43:19 +05:30
if (currentSelected != null && isManualSelection) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'manual',
scope: 'models/default',
data: {'name': currentSelected.name},
);
return currentSelected;
}
// Get demo models and select the first one
final models = await ref.read(modelsProvider.future);
if (models.isNotEmpty) {
final defaultModel = models.first;
2025-08-28 18:54:06 +05:30
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(defaultModel);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'auto-select',
scope: 'models/default',
data: {'name': defaultModel.name},
2025-08-28 18:54:06 +05:30
);
}
return defaultModel;
}
DebugLogger.warning('no-demo-models', scope: 'models/default');
return null;
}
2025-09-27 17:29:15 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('no-api', scope: 'models/default');
return null;
}
DebugLogger.log('api-available', scope: 'models/default');
2025-08-10 01:20:45 +05:30
try {
2025-09-16 20:10:53 +05:30
// Respect manual selection if present
if (ref.read(isManualModelSelectionProvider)) {
final current = ref.read(selectedModelProvider);
if (current != null) return current;
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
// 1) Fast path: read stored default model ID directly and select optimistically
try {
final storedDefaultId = await SettingsService.getDefaultModel();
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
if (!ref.read(isManualModelSelectionProvider)) {
final placeholder = Model(
id: storedDefaultId,
name: storedDefaultId,
supportsStreaming: true,
2025-09-01 18:49:43 +05:30
);
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(placeholder);
2025-09-01 18:49:43 +05:30
}
2025-09-16 20:10:53 +05:30
// Reconcile against real models in background
Future.microtask(() async {
try {
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == storedDefaultId);
} catch (_) {
final byName = models
.where((m) => m.name == storedDefaultId)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'reconcile',
scope: 'models/default',
data: {'name': resolved.name, 'source': 'stored'},
2025-09-01 18:49:43 +05:30
);
}
} catch (e) {
DebugLogger.error(
'reconcile-failed',
scope: 'models/default',
error: e,
);
}
2025-09-16 20:10:53 +05:30
});
return ref.read(selectedModelProvider);
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
} catch (_) {}
2025-08-10 01:20:45 +05:30
2025-09-16 20:10:53 +05:30
// 2) Fast server path: query server default ID without listing all models
2025-08-10 01:20:45 +05:30
try {
2025-09-16 20:10:53 +05:30
final serverDefault = await api.getDefaultModel();
if (serverDefault != null && serverDefault.isNotEmpty) {
2025-08-28 18:54:06 +05:30
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-16 20:10:53 +05:30
final placeholder = Model(
id: serverDefault,
name: serverDefault,
supportsStreaming: true,
2025-08-28 18:54:06 +05:30
);
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(placeholder);
2025-08-28 18:54:06 +05:30
}
2025-09-16 20:10:53 +05:30
// Reconcile against real models in background
Future.microtask(() async {
try {
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == serverDefault);
} catch (_) {
final byName = models
.where((m) => m.name == serverDefault)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (!ref.mounted) return;
2025-09-16 20:10:53 +05:30
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(resolved);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'reconcile',
scope: 'models/default',
data: {'name': resolved.name, 'source': 'server'},
2025-09-16 20:10:53 +05:30
);
}
} catch (e) {
DebugLogger.error(
'reconcile-failed',
scope: 'models/default',
error: e,
);
}
2025-09-16 20:10:53 +05:30
});
return ref.read(selectedModelProvider);
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
} catch (_) {}
// 3) Fallback: fetch models and pick first available
DebugLogger.log('fallback-path', scope: 'models/default');
2025-09-16 20:10:53 +05:30
final models = await ref.read(modelsProvider.future);
DebugLogger.log(
'models-loaded',
scope: 'models/default',
data: {'count': models.length},
);
2025-09-16 20:10:53 +05:30
if (models.isEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('no-models', scope: 'models/default');
2025-09-16 20:10:53 +05:30
return null;
}
final selectedModel = models.first;
if (!ref.read(isManualModelSelectionProvider)) {
2025-09-21 22:31:44 +05:30
ref.read(selectedModelProvider.notifier).set(selectedModel);
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fallback-selected',
2025-09-25 22:36:42 +05:30
scope: 'models/default',
data: {'name': selectedModel.name, 'id': selectedModel.id},
);
} else {
DebugLogger.log('skip-manual-override', scope: 'models/default');
2025-08-10 01:20:45 +05:30
}
2025-09-16 20:10:53 +05:30
return selectedModel;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('set-default-failed', scope: 'models/default', error: e);
2025-08-10 01:20:45 +05:30
return null;
}
}
2025-08-10 01:20:45 +05:30
// Background model loading provider that doesn't block UI
// This just schedules the loading, doesn't wait for it
final backgroundModelLoadProvider = Provider<void>((ref) {
// Ensure API token updater is initialized
ref.watch(apiTokenUpdaterProvider);
// Watch auth state to trigger model loading when authenticated
final navState = ref.watch(authNavigationStateProvider);
2025-09-28 20:59:19 +05:30
if (navState != AuthNavigationState.authenticated) {
DebugLogger.log('skip-not-authed', scope: 'models/background');
2025-09-28 20:59:19 +05:30
return;
}
2025-08-10 01:20:45 +05:30
// Use a flag to prevent multiple concurrent loads
var isLoading = false;
2025-09-28 20:59:19 +05:30
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isLoading) return;
isLoading = true;
2025-09-28 20:59:19 +05:30
// Schedule background loading without blocking startup frame
Future.microtask(() async {
// Reduced delay for faster startup model selection
await Future.delayed(const Duration(milliseconds: 100));
if (!ref.mounted) {
DebugLogger.log('cancelled-unmounted', scope: 'models/background');
return;
}
2025-08-10 01:20:45 +05:30
2025-09-28 20:59:19 +05:30
DebugLogger.log('bg-start', scope: 'models/background');
try {
final model = await ref.read(defaultModelProvider.future);
if (!ref.mounted) {
DebugLogger.log('complete-unmounted', scope: 'models/background');
return;
}
DebugLogger.log(
'bg-complete',
scope: 'models/background',
data: {'model': model?.name ?? 'null'},
);
2025-09-28 20:59:19 +05:30
} catch (e) {
DebugLogger.error('bg-failed', scope: 'models/background', error: e);
} finally {
isLoading = false;
2025-09-28 20:59:19 +05:30
}
});
2025-08-10 01:20:45 +05:30
});
return;
});
// Search query provider
@Riverpod(keepAlive: true)
class SearchQuery extends _$SearchQuery {
2025-09-21 22:31:44 +05:30
@override
String build() => '';
void set(String query) => state = query;
}
2025-08-10 01:20:45 +05:30
// Server-side search provider for chats
@riverpod
Future<List<Conversation>> serverSearch(Ref ref, String query) async {
2025-08-10 01:20:45 +05:30
if (query.trim().isEmpty) {
// Return empty list for empty query instead of all conversations
return [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
2025-09-25 22:36:42 +05:30
final trimmedQuery = query.trim();
DebugLogger.log(
'server-search',
scope: 'search',
data: {'length': trimmedQuery.length},
);
2025-08-10 01:20:45 +05:30
// Use the new server-side search API
final chatHits = await api.searchChats(
2025-09-25 22:36:42 +05:30
query: trimmedQuery,
2025-08-10 01:20:45 +05:30
archived: false, // Only search non-archived conversations
limit: 50,
sortBy: 'updated_at',
sortOrder: 'desc',
);
// chatHits is already List<Conversation>
final List<Conversation> conversations = List.of(chatHits);
2025-08-10 01:20:45 +05:30
// Perform message-level search and merge chat hits
try {
final messageHits = await api.searchMessages(
2025-09-25 22:36:42 +05:30
query: trimmedQuery,
limit: 100,
);
// Build a set of conversation IDs already present from chat search
final existingIds = conversations.map((c) => c.id).toSet();
// Extract chat ids from message hits (supporting multiple key casings)
final messageChatIds = <String>{};
for (final hit in messageHits) {
final chatId =
(hit['chat_id'] ?? hit['chatId'] ?? hit['chatID']) as String?;
if (chatId != null && chatId.isNotEmpty) {
messageChatIds.add(chatId);
}
}
2025-08-10 01:20:45 +05:30
// Determine which chat ids we still need to fetch
final idsToFetch = messageChatIds
.where((id) => !existingIds.contains(id))
.toList();
// Fetch conversations for those ids in parallel (cap to avoid overload)
const maxFetch = 50;
final fetchList = idsToFetch.take(maxFetch).toList();
if (fetchList.isNotEmpty) {
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-from-messages',
scope: 'search',
data: {'count': fetchList.length},
);
final fetched = await Future.wait(
fetchList.map((id) async {
try {
return await api.getConversation(id);
} catch (_) {
return null;
}
}),
);
// Merge fetched conversations
for (final conv in fetched) {
if (conv != null && !existingIds.contains(conv.id)) {
conversations.add(conv);
existingIds.add(conv.id);
}
}
// Optional: sort by updated date desc to keep results consistent
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
}
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('message-search-failed', scope: 'search', error: e);
}
2025-08-10 01:20:45 +05:30
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'server-results',
scope: 'search',
data: {'count': conversations.length},
);
2025-08-10 01:20:45 +05:30
return conversations;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('server-search-failed', scope: 'search', error: e);
2025-08-10 01:20:45 +05:30
// Fallback to local search if server search fails
final allConversations = await ref.read(conversationsProvider.future);
2025-09-25 22:36:42 +05:30
DebugLogger.log('fallback-local', scope: 'search');
2025-08-10 01:20:45 +05:30
return allConversations.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) =>
msg.content.toLowerCase().contains(query.toLowerCase()),
));
}).toList();
}
}
2025-08-10 01:20:45 +05:30
final filteredConversationsProvider = Provider<List<Conversation>>((ref) {
final conversations = ref.watch(conversationsProvider);
final query = ref.watch(searchQueryProvider);
// Use server-side search when there's a query
if (query.trim().isNotEmpty) {
final searchResults = ref.watch(serverSearchProvider(query));
return searchResults.maybeWhen(
data: (results) => results,
loading: () {
// While server search is loading, show local filtered results
return conversations.maybeWhen(
data: (convs) => convs.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) => msg.content.toLowerCase().contains(
query.toLowerCase(),
),
));
}).toList(),
orElse: () => [],
);
},
error: (_, stackTrace) {
// On error, fallback to local search
return conversations.maybeWhen(
data: (convs) => convs.where((conv) {
return !conv.archived &&
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
conv.messages.any(
(msg) => msg.content.toLowerCase().contains(
query.toLowerCase(),
),
));
}).toList(),
orElse: () => [],
);
},
orElse: () => [],
);
}
// When no search query, show all non-archived conversations
return conversations.maybeWhen(
data: (convs) {
if (ref.watch(reviewerModeProvider)) {
return convs; // Already filtered above for demo
}
// Filter out archived conversations (they should be in a separate view)
final filtered = convs.where((conv) => !conv.archived).toList();
// Sort: pinned conversations first, then by updated date
filtered.sort((a, b) {
// Pinned conversations come first
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
// Within same pin status, sort by updated date (newest first)
return b.updatedAt.compareTo(a.updatedAt);
});
return filtered;
},
orElse: () => [],
);
});
// Provider for archived conversations
final archivedConversationsProvider = Provider<List<Conversation>>((ref) {
final conversations = ref.watch(conversationsProvider);
return conversations.maybeWhen(
data: (convs) {
if (ref.watch(reviewerModeProvider)) {
return convs.where((c) => c.archived).toList();
}
// Only show archived conversations
final archived = convs.where((conv) => conv.archived).toList();
// Sort by updated date (newest first)
archived.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return archived;
},
orElse: () => [],
);
});
// Reviewer mode provider (persisted)
@Riverpod(keepAlive: true)
class ReviewerMode extends _$ReviewerMode {
2025-09-21 22:31:44 +05:30
late final OptimizedStorageService _storage;
bool _initialized = false;
@override
bool build() {
_storage = ref.watch(optimizedStorageServiceProvider);
if (!_initialized) {
_initialized = true;
Future.microtask(_load);
}
return false;
2025-08-10 01:20:45 +05:30
}
2025-09-21 22:31:44 +05:30
2025-08-10 01:20:45 +05:30
Future<void> _load() async {
final enabled = await _storage.getReviewerMode();
2025-09-21 22:31:44 +05:30
if (!ref.mounted) {
return;
}
2025-08-10 01:20:45 +05:30
state = enabled;
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
await _storage.setReviewerMode(enabled);
}
Future<void> toggle() => setEnabled(!state);
}
// User Settings providers
@Riverpod(keepAlive: true)
Future<UserSettings> userSettings(Ref ref) async {
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) {
// Return default settings if no API
return const UserSettings();
}
try {
final settingsData = await api.getUserSettings();
return UserSettings.fromJson(settingsData);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('user-settings-failed', scope: 'settings', error: e);
2025-08-10 01:20:45 +05:30
// Return default settings on error
return const UserSettings();
}
}
2025-08-10 01:20:45 +05:30
// Conversation Suggestions provider
@Riverpod(keepAlive: true)
Future<List<String>> conversationSuggestions(Ref ref) async {
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getSuggestions();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('suggestions-failed', scope: 'suggestions', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}
2025-08-10 01:20:45 +05:30
2025-08-21 14:37:49 +05:30
// Server features and permissions
@Riverpod(keepAlive: true)
Future<Map<String, dynamic>> userPermissions(Ref ref) async {
2025-08-21 14:37:49 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return {};
try {
return await api.getUserPermissions();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('permissions-failed', scope: 'permissions', error: e);
2025-08-21 14:37:49 +05:30
return {};
}
}
2025-08-21 14:37:49 +05:30
final imageGenerationAvailableProvider = Provider<bool>((ref) {
final perms = ref.watch(userPermissionsProvider);
return perms.maybeWhen(
data: (data) {
final features = data['features'];
if (features is Map<String, dynamic>) {
final value = features['image_generation'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
}
return false;
},
orElse: () => false,
);
});
final webSearchAvailableProvider = Provider<bool>((ref) {
final perms = ref.watch(userPermissionsProvider);
return perms.maybeWhen(
data: (data) {
final features = data['features'];
if (features is Map<String, dynamic>) {
final value = features['web_search'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
}
return false;
},
orElse: () => false,
);
});
2025-08-10 01:20:45 +05:30
// Folders provider
@Riverpod(keepAlive: true)
Future<List<Folder>> folders(Ref ref) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'folders');
return [];
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
2025-08-17 00:05:30 +05:30
if (api == null) {
2025-09-25 22:36:42 +05:30
DebugLogger.warning('api-missing', scope: 'folders');
2025-08-17 00:05:30 +05:30
return [];
}
2025-08-10 01:20:45 +05:30
try {
final foldersData = await api.getFolders();
2025-08-17 00:05:30 +05:30
final folders = foldersData
2025-08-10 01:20:45 +05:30
.map((folderData) => Folder.fromJson(folderData))
.toList();
2025-09-25 22:36:42 +05:30
DebugLogger.log(
'fetch-ok',
scope: 'folders',
data: {'count': folders.length},
);
2025-08-17 00:05:30 +05:30
return folders;
2025-08-10 01:20:45 +05:30
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('fetch-failed', scope: 'folders', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}
2025-08-10 01:20:45 +05:30
// Files provider
@Riverpod(keepAlive: true)
Future<List<FileInfo>> userFiles(Ref ref) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'files');
return [];
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final filesData = await api.getUserFiles();
return filesData.map((fileData) => FileInfo.fromJson(fileData)).toList();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('files-failed', scope: 'files', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}
2025-08-10 01:20:45 +05:30
// File content provider
@riverpod
Future<String> fileContent(Ref ref, String fileId) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'files/content');
throw Exception('Not authenticated');
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) throw Exception('No API service available');
try {
return await api.getFileContent(fileId);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'file-content-failed',
scope: 'files',
error: e,
data: {'fileId': fileId},
);
2025-08-10 01:20:45 +05:30
throw Exception('Failed to load file content: $e');
}
}
2025-08-10 01:20:45 +05:30
// Knowledge Base providers
@Riverpod(keepAlive: true)
Future<List<KnowledgeBase>> knowledgeBases(Ref ref) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'knowledge');
return [];
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
final kbData = await api.getKnowledgeBases();
return kbData.map((data) => KnowledgeBase.fromJson(data)).toList();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('knowledge-bases-failed', scope: 'knowledge', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}
2025-08-10 01:20:45 +05:30
@riverpod
Future<List<KnowledgeBaseItem>> knowledgeBaseItems(Ref ref, String kbId) async {
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'knowledge/items');
return [];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
2025-08-10 01:20:45 +05:30
try {
final itemsData = await api.getKnowledgeBaseItems(kbId);
return itemsData.map((data) => KnowledgeBaseItem.fromJson(data)).toList();
} catch (e) {
DebugLogger.error('knowledge-items-failed', scope: 'knowledge', error: e);
return [];
}
}
2025-08-10 01:20:45 +05:30
// Audio providers
@Riverpod(keepAlive: true)
Future<List<String>> availableVoices(Ref ref) async {
2025-09-28 20:41:35 +05:30
// Protected: require authentication
if (!ref.read(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'voices');
return [];
}
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getAvailableVoices();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('voices-failed', scope: 'voices', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}
2025-08-10 01:20:45 +05:30
// Image Generation providers
@Riverpod(keepAlive: true)
Future<List<Map<String, dynamic>>> imageModels(Ref ref) async {
2025-08-10 01:20:45 +05:30
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
return await api.getImageModels();
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('image-models-failed', scope: 'image-models', error: e);
2025-08-10 01:20:45 +05:30
return [];
}
}