diff --git a/lib/core/network/image_header_utils.dart b/lib/core/network/image_header_utils.dart index b77d1e3..37d5a6d 100644 --- a/lib/core/network/image_header_utils.dart +++ b/lib/core/network/image_header_utils.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:conduit/core/providers/app_providers.dart'; +import 'package:conduit/core/services/api_service.dart'; import 'package:conduit/features/auth/providers/unified_auth_providers.dart'; /// Builds HTTP headers for protected image requests. @@ -8,14 +9,14 @@ import 'package:conduit/features/auth/providers/unified_auth_providers.dart'; /// Includes Authorization (Bearer token or API key) and any server-configured /// custom headers. Returns `null` if no headers are needed. Map? buildImageHeadersFromRef(Ref ref) { - final api = ref.read(apiServiceProvider); - final token = ref.read(authTokenProvider3); + final api = ref.watch(apiServiceProvider); + final token = ref.watch(authTokenProvider3); return _build(api, token); } Map? buildImageHeadersFromWidgetRef(WidgetRef ref) { - final api = ref.read(apiServiceProvider); - final token = ref.read(authTokenProvider3); + final api = ref.watch(apiServiceProvider); + final token = ref.watch(authTokenProvider3); return _build(api, token); } @@ -29,7 +30,7 @@ Map? buildImageHeadersFromContainer( return _build(api, token); } -Map? _build(dynamic api, String? token) { +Map? _build(ApiService? api, String? token) { final headers = {}; if (token != null && token.isNotEmpty) { @@ -39,8 +40,9 @@ Map? _build(dynamic api, String? token) { headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}'; } - if (api != null && api.serverConfig.customHeaders.isNotEmpty) { - headers.addAll(api.serverConfig.customHeaders); + final customHeaders = api?.serverConfig.customHeaders ?? {}; + if (customHeaders.isNotEmpty) { + headers.addAll(customHeaders); } return headers.isEmpty ? null : headers; diff --git a/lib/core/network/self_signed_image_cache_manager.dart b/lib/core/network/self_signed_image_cache_manager.dart index f2ac8c4..b0a4745 100644 --- a/lib/core/network/self_signed_image_cache_manager.dart +++ b/lib/core/network/self_signed_image_cache_manager.dart @@ -14,7 +14,7 @@ import '../providers/app_providers.dart'; /// Notes /// - Scoped to the configured host and (optionally) port only. /// - Not available on web (browsers enforce TLS validation). -final selfSignedImageCacheManagerProvider = Provider((ref) { +final selfSignedImageCacheManagerProvider = Provider((ref) { final active = ref.watch(activeServerProvider); return active.maybeWhen( @@ -26,7 +26,7 @@ final selfSignedImageCacheManagerProvider = Provider((ref) { ); }); -CacheManager? _buildForServer(ServerConfig server) { +BaseCacheManager? _buildForServer(ServerConfig server) { if (kIsWeb) return null; if (!server.allowSelfSignedCertificates) return null; diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 6687d5a..cd331a4 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -26,6 +26,7 @@ import '../services/optimized_storage_service.dart'; import '../services/socket_service.dart'; import '../utils/debug_logger.dart'; import '../models/socket_event.dart'; +import '../services/worker_manager.dart'; import '../../shared/theme/tweakcn_themes.dart'; import '../../shared/theme/app_theme.dart'; import '../../features/tools/providers/tools_providers.dart'; @@ -57,6 +58,7 @@ final optimizedStorageServiceProvider = Provider(( return OptimizedStorageService( secureStorage: ref.watch(secureStorageProvider), boxes: ref.watch(hiveBoxesProvider), + workerManager: ref.watch(workerManagerProvider), ); }); @@ -259,6 +261,7 @@ final apiServiceProvider = Provider((ref) { return null; } final activeServer = ref.watch(activeServerProvider); + final workerManager = ref.watch(workerManagerProvider); return activeServer.maybeWhen( data: (server) { @@ -266,6 +269,7 @@ final apiServiceProvider = Provider((ref) { final apiService = ApiService( serverConfig: server, + workerManager: workerManager, authToken: null, // Will be set by auth state manager ); @@ -879,311 +883,345 @@ class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp { void set(DateTime? timestamp) => state = timestamp; } -/// 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. +/// Clears the in-memory timestamp cache and triggers a refresh of the +/// conversations provider. Optionally refreshes the folders provider so folder +/// metadata stays in sync. void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) { ref.read(_conversationsCacheTimestampProvider.notifier).set(null); - ref.invalidate(conversationsProvider); + final notifier = ref.read(conversationsProvider.notifier); + unawaited(notifier.refresh(includeFolders: includeFolders)); if (includeFolders) { - ref.invalidate(foldersProvider); + final foldersNotifier = ref.read(foldersProvider.notifier); + unawaited(foldersNotifier.refresh()); } } -// Conversation providers - Now using correct OpenWebUI API with caching -// keepAlive to maintain cache during authenticated session +// Conversation providers - Now using correct OpenWebUI API with caching and +// immediate mutation helpers. @Riverpod(keepAlive: true) -Future> conversations(Ref ref) async { - // 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); - if (!authed) { - DebugLogger.log('skip-unauthed', scope: 'conversations'); - return []; - } - // Check if we have a recent cache (within 5 seconds) - final lastFetch = ref.read(_conversationsCacheTimestampProvider); - if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) { - DebugLogger.log( - 'cache-hit', - scope: 'conversations', - data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds}, - ); - // Note: Can't read our own provider here, would cause a cycle - // The caching is handled by Riverpod's built-in mechanism - } - 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)), - messages: [ - ChatMessage( - id: 'demo-msg-1', - role: 'assistant', - content: - '**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.', - timestamp: DateTime.now().subtract(const Duration(minutes: 10)), - model: 'Gemma 2 Mini (Demo)', - isStreaming: false, - ), - ], - ), - ]; - } - final api = ref.watch(apiServiceProvider); - if (api == null) { - DebugLogger.warning('api-missing', scope: 'conversations'); - return []; +class Conversations extends _$Conversations { + @override + Future> build() async { + final authed = ref.watch(isAuthenticatedProvider2); + if (!authed) { + DebugLogger.log('skip-unauthed', scope: 'conversations'); + _updateCacheTimestamp(null); + return const []; + } + + if (ref.watch(reviewerModeProvider)) { + return _demoConversations(); + } + + return _loadRemoteConversations(); } - try { - DebugLogger.log('fetch-start', scope: 'conversations'); - final conversations = await api - .getConversations(); // Fetch all conversations - DebugLogger.log( - 'fetch-ok', - scope: 'conversations', - data: {'count': conversations.length}, - ); + Future refresh({bool includeFolders = false}) async { + final authed = ref.read(isAuthenticatedProvider2); + if (!authed) { + _updateCacheTimestamp(null); + state = AsyncData>([]); + if (includeFolders) { + unawaited(ref.read(foldersProvider.notifier).refresh()); + } + return; + } + + if (ref.read(reviewerModeProvider)) { + state = AsyncData>(_demoConversations()); + if (includeFolders) { + unawaited(ref.read(foldersProvider.notifier).refresh()); + } + return; + } + + final result = await AsyncValue.guard(_loadRemoteConversations); + if (!ref.mounted) return; + state = result; + if (includeFolders) { + unawaited(ref.read(foldersProvider.notifier).refresh()); + } + } + + void removeConversation(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((conversation) => conversation.id != id) + .toList(growable: true); + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + void upsertConversation(Conversation conversation) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere( + (element) => element.id == conversation.id, + ); + if (index >= 0) { + updated[index] = conversation; + } else { + updated.add(conversation); + } + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + void updateConversation( + String id, + Conversation Function(Conversation conversation) transform, + ) { + final current = state.asData?.value; + if (current == null) return; + final index = current.indexWhere((conversation) => conversation.id == id); + if (index < 0) return; + final updated = [...current]; + updated[index] = transform(updated[index]); + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + List _demoConversations() => [ + Conversation( + id: 'demo-conv-1', + title: 'Welcome to Conduit (Demo)', + createdAt: DateTime.now().subtract(const Duration(minutes: 15)), + updatedAt: DateTime.now().subtract(const Duration(minutes: 10)), + messages: [ + ChatMessage( + id: 'demo-msg-1', + role: 'assistant', + content: + '**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.', + timestamp: DateTime.now().subtract(const Duration(minutes: 10)), + model: 'Gemma 2 Mini (Demo)', + isStreaming: false, + ), + ], + ), + ]; + + Future> _loadRemoteConversations() async { + final api = ref.watch(apiServiceProvider); + if (api == null) { + DebugLogger.warning('api-missing', scope: 'conversations'); + return const []; + } - // Also fetch folder information and update conversations with folder IDs try { - final foldersData = await api.getFolders(); + DebugLogger.log('fetch-start', scope: 'conversations'); + final conversations = await api.getConversations(); DebugLogger.log( - 'folders-fetched', + 'fetch-ok', scope: 'conversations', - data: {'count': foldersData.length}, + data: {'count': conversations.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 = {}; - for (final folder in folders) { + try { + final foldersData = await api.getFolders(); DebugLogger.log( - 'folder', - scope: 'conversations/map', - data: { - 'id': folder.id, - 'name': folder.name, - 'count': folder.conversationIds.length, - }, + 'folders-fetched', + scope: 'conversations', + data: {'count': foldersData.length}, ); - for (final conversationId in folder.conversationIds) { - conversationToFolder[conversationId] = folder.id; - DebugLogger.log( - 'map', - scope: 'conversations/map', - data: {'conversationId': conversationId, 'folderId': folder.id}, - ); - } - } - // 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 = {}; + final folders = foldersData + .map((folderData) => Folder.fromJson(folderData)) + .toList(); - 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, - ); + final conversationToFolder = {}; + for (final folder in folders) { DebugLogger.log( - 'update-folder', + 'folder', scope: 'conversations/map', data: { - 'conversationId': conversation.id, - 'folderId': folderIdToUse, - 'explicit': explicitFolderId != null, + 'id': folder.id, + 'name': folder.name, + 'count': folder.conversationIds.length, + }, + ); + for (final conversationId in folder.conversationIds) { + conversationToFolder[conversationId] = folder.id; + DebugLogger.log( + 'map', + scope: 'conversations/map', + data: {'conversationId': conversationId, 'folderId': folder.id}, + ); + } + } + + final conversationMap = {}; + + for (final conversation in conversations) { + final explicitFolderId = conversation.folderId; + final mappedFolderId = conversationToFolder[conversation.id]; + final folderIdToUse = explicitFolderId ?? mappedFolderId; + if (folderIdToUse != null) { + conversationMap[conversation.id] = conversation.copyWith( + folderId: folderIdToUse, + ); + DebugLogger.log( + 'update-folder', + scope: 'conversations/map', + data: { + 'conversationId': conversation.id, + 'folderId': folderIdToUse, + 'explicit': explicitFolderId != null, + }, + ); + } else { + conversationMap[conversation.id] = conversation; + } + } + + final existingIds = conversationMap.keys.toSet(); + final missingInBase = conversationToFolder.keys + .where((id) => !existingIds.contains(id)) + .toList(); + if (missingInBase.isNotEmpty) { + DebugLogger.warning( + 'missing-in-base', + scope: 'conversations/map', + data: { + 'count': missingInBase.length, + 'preview': missingInBase.take(5).toList(), }, ); } else { - conversationMap[conversation.id] = conversation; + DebugLogger.log('folders-synced', scope: 'conversations/map'); } - } - // 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(); + for (final folder in folders) { + final missingIds = folder.conversationIds + .where((id) => !existingIds.contains(id)) + .toList(); - // 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) { - DebugLogger.warning( - 'missing-in-base', - scope: 'conversations/map', - data: { - 'count': missingInBase.length, - 'preview': missingInBase.take(5).toList(), - }, + final hasKnownConversations = conversationMap.values.any( + (conversation) => conversation.folderId == folder.id, + ); + + final shouldFetchFolder = + missingIds.isNotEmpty || + (!hasKnownConversations && folder.conversationIds.isEmpty); + + List folderConvs = const []; + if (shouldFetchFolder) { + try { + folderConvs = await api.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}, + ); + } + } + + final fetchedMap = {for (final c in folderConvs) c.id: c}; + + for (final convId in missingIds) { + final fetched = fetchedMap[convId]; + if (fetched != null) { + final toAdd = fetched.folderId == null + ? fetched.copyWith(folderId: folder.id) + : fetched; + conversationMap[toAdd.id] = toAdd; + existingIds.add(toAdd.id); + DebugLogger.log( + 'add-missing', + scope: 'conversations/map', + data: {'conversationId': toAdd.id, 'folderId': folder.id}, + ); + } else { + final placeholder = Conversation( + id: convId, + title: 'Chat', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: const [], + folderId: folder.id, + ); + conversationMap[convId] = placeholder; + existingIds.add(convId); + DebugLogger.log( + 'add-placeholder', + scope: 'conversations/map', + data: {'conversationId': convId, 'folderId': folder.id}, + ); + } + } + + 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}, + ); + } + } + } + + final sortedConversations = _sortByUpdatedAt( + conversationMap.values.toList(), ); - } else { - 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, + DebugLogger.log( + 'sort', + scope: 'conversations', + data: {'source': 'folder-sync'}, ); - - final shouldFetchFolder = - apiSvc != null && - (missingIds.isNotEmpty || - (!hasKnownConversations && folder.conversationIds.isEmpty)); - - List 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}, - ); - } - } - - // 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); - 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); - DebugLogger.log( - 'add-placeholder', - scope: 'conversations/map', - data: {'conversationId': convId, 'folderId': folder.id}, - ); - } - } - - 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}, - ); - } - } + _updateCacheTimestamp(DateTime.now()); + return sortedConversations; + } catch (e) { + DebugLogger.error( + 'folders-fetch-failed', + scope: 'conversations', + error: e, + ); + final sorted = _sortByUpdatedAt(conversations.toList()); + DebugLogger.log( + 'sort', + scope: 'conversations', + data: {'source': 'fallback'}, + ); + _updateCacheTimestamp(DateTime.now()); + return sorted; } - - // Convert map back to list - this ensures no duplicates by ID - final sortedConversations = conversationMap.values.toList(); - - // Sort conversations by updatedAt in descending order (most recent first) - sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - DebugLogger.log( - 'sort', - scope: 'conversations', - data: {'source': 'folder-sync'}, - ); - - // Update cache timestamp - ref - .read(_conversationsCacheTimestampProvider.notifier) - .set(DateTime.now()); - - return sortedConversations; - } catch (e) { + } catch (e, stackTrace) { DebugLogger.error( - 'folders-fetch-failed', + 'fetch-failed', scope: 'conversations', error: e, + stackTrace: stackTrace, ); - // Sort conversations even when folder fetch fails - conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - DebugLogger.log( - 'sort', - scope: 'conversations', - data: {'source': 'fallback'}, - ); - - // Update cache timestamp - ref - .read(_conversationsCacheTimestampProvider.notifier) - .set(DateTime.now()); - - return conversations; // Return original conversations if folder fetch fails + if (e.toString().contains('403')) { + DebugLogger.warning('endpoint-403', scope: 'conversations'); + } + return const []; } - } catch (e, stackTrace) { - DebugLogger.error( - 'fetch-failed', - scope: 'conversations', - error: e, - stackTrace: stackTrace, - ); + } - // 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')) { - DebugLogger.warning('endpoint-403', scope: 'conversations'); - } + List _sortByUpdatedAt(List conversations) { + final sorted = [...conversations]; + sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return List.unmodifiable(sorted); + } - // Return empty list instead of re-throwing to allow app to continue functioning - return []; + void _updateCacheTimestamp(DateTime? timestamp) { + ref.read(_conversationsCacheTimestampProvider.notifier).set(timestamp); } } @@ -1789,52 +1827,166 @@ final webSearchAvailableProvider = Provider((ref) { // Folders provider @Riverpod(keepAlive: true) -Future> folders(Ref ref) async { - // Protected: require authentication - if (!ref.read(isAuthenticatedProvider2)) { - DebugLogger.log('skip-unauthed', scope: 'folders'); - return []; - } - final api = ref.watch(apiServiceProvider); - if (api == null) { - DebugLogger.warning('api-missing', scope: 'folders'); - return []; +class Folders extends _$Folders { + @override + Future> build() async { + if (!ref.watch(isAuthenticatedProvider2)) { + DebugLogger.log('skip-unauthed', scope: 'folders'); + return const []; + } + final api = ref.watch(apiServiceProvider); + if (api == null) { + DebugLogger.warning('api-missing', scope: 'folders'); + return const []; + } + return _load(api); } - try { - final foldersData = await api.getFolders(); - final folders = foldersData - .map((folderData) => Folder.fromJson(folderData)) - .toList(); - DebugLogger.log( - 'fetch-ok', - scope: 'folders', - data: {'count': folders.length}, - ); - return folders; - } catch (e) { - DebugLogger.error('fetch-failed', scope: 'folders', error: e); - return []; + Future refresh() async { + if (!ref.read(isAuthenticatedProvider2)) { + state = const AsyncData>([]); + return; + } + final api = ref.read(apiServiceProvider); + if (api == null) { + state = const AsyncData>([]); + return; + } + final result = await AsyncValue.guard(() => _load(api)); + if (!ref.mounted) return; + state = result; + } + + void upsertFolder(Folder folder) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere((existing) => existing.id == folder.id); + if (index >= 0) { + updated[index] = folder; + } else { + updated.add(folder); + } + state = AsyncData>(_sort(updated)); + } + + void updateFolder(String id, Folder Function(Folder folder) transform) { + final current = state.asData?.value; + if (current == null) return; + final index = current.indexWhere((folder) => folder.id == id); + if (index < 0) return; + final updated = [...current]; + updated[index] = transform(updated[index]); + state = AsyncData>(_sort(updated)); + } + + void removeFolder(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((folder) => folder.id != id) + .toList(growable: true); + state = AsyncData>(_sort(updated)); + } + + Future> _load(ApiService api) async { + try { + final foldersData = await api.getFolders(); + final folders = foldersData + .map((folderData) => Folder.fromJson(folderData)) + .toList(); + DebugLogger.log( + 'fetch-ok', + scope: 'folders', + data: {'count': folders.length}, + ); + return _sort(folders); + } catch (e, stackTrace) { + DebugLogger.error( + 'fetch-failed', + scope: 'folders', + error: e, + stackTrace: stackTrace, + ); + return const []; + } + } + + List _sort(List input) { + final sorted = [...input]; + sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return List.unmodifiable(sorted); } } // Files provider @Riverpod(keepAlive: true) -Future> userFiles(Ref ref) async { - // Protected: require authentication - if (!ref.read(isAuthenticatedProvider2)) { - DebugLogger.log('skip-unauthed', scope: 'files'); - return []; +class UserFiles extends _$UserFiles { + @override + Future> build() async { + if (!ref.watch(isAuthenticatedProvider2)) { + DebugLogger.log('skip-unauthed', scope: 'files'); + return const []; + } + final api = ref.watch(apiServiceProvider); + if (api == null) return const []; + return _load(api); } - 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) { - DebugLogger.error('files-failed', scope: 'files', error: e); - return []; + Future refresh() async { + if (!ref.read(isAuthenticatedProvider2)) { + state = const AsyncData>([]); + return; + } + final api = ref.read(apiServiceProvider); + if (api == null) { + state = const AsyncData>([]); + return; + } + final result = await AsyncValue.guard(() => _load(api)); + if (!ref.mounted) return; + state = result; + } + + void upsert(FileInfo file) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere((existing) => existing.id == file.id); + if (index >= 0) { + updated[index] = file; + } else { + updated.add(file); + } + state = AsyncData>(_sort(updated)); + } + + void remove(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((file) => file.id != id) + .toList(growable: true); + state = AsyncData>(_sort(updated)); + } + + Future> _load(ApiService api) async { + try { + final files = await api.getUserFiles(); + return _sort(files); + } catch (e, stackTrace) { + DebugLogger.error( + 'files-failed', + scope: 'files', + error: e, + stackTrace: stackTrace, + ); + return const []; + } + } + + List _sort(List input) { + final sorted = [...input]; + sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return List.unmodifiable(sorted); } } @@ -1864,21 +2016,75 @@ Future fileContent(Ref ref, String fileId) async { // Knowledge Base providers @Riverpod(keepAlive: true) -Future> knowledgeBases(Ref ref) async { - // Protected: require authentication - if (!ref.read(isAuthenticatedProvider2)) { - DebugLogger.log('skip-unauthed', scope: 'knowledge'); - return []; +class KnowledgeBases extends _$KnowledgeBases { + @override + Future> build() async { + if (!ref.watch(isAuthenticatedProvider2)) { + DebugLogger.log('skip-unauthed', scope: 'knowledge'); + return const []; + } + final api = ref.watch(apiServiceProvider); + if (api == null) return const []; + return _load(api); } - 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) { - DebugLogger.error('knowledge-bases-failed', scope: 'knowledge', error: e); - return []; + Future refresh() async { + if (!ref.read(isAuthenticatedProvider2)) { + state = const AsyncData>([]); + return; + } + final api = ref.read(apiServiceProvider); + if (api == null) { + state = const AsyncData>([]); + return; + } + final result = await AsyncValue.guard(() => _load(api)); + if (!ref.mounted) return; + state = result; + } + + void upsert(KnowledgeBase knowledgeBase) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere( + (existing) => existing.id == knowledgeBase.id, + ); + if (index >= 0) { + updated[index] = knowledgeBase; + } else { + updated.add(knowledgeBase); + } + state = AsyncData>(_sort(updated)); + } + + void remove(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((knowledgeBase) => knowledgeBase.id != id) + .toList(growable: true); + state = AsyncData>(_sort(updated)); + } + + Future> _load(ApiService api) async { + try { + final knowledgeBases = await api.getKnowledgeBases(); + return _sort(knowledgeBases); + } catch (e, stackTrace) { + DebugLogger.error( + 'knowledge-bases-failed', + scope: 'knowledge', + error: e, + stackTrace: stackTrace, + ); + return const []; + } + } + + List _sort(List input) { + final sorted = [...input]; + sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return List.unmodifiable(sorted); } } @@ -1893,8 +2099,7 @@ Future> knowledgeBaseItems(Ref ref, String kbId) async { if (api == null) return []; try { - final itemsData = await api.getKnowledgeBaseItems(kbId); - return itemsData.map((data) => KnowledgeBaseItem.fromJson(data)).toList(); + return await api.getKnowledgeBaseItems(kbId); } catch (e) { DebugLogger.error('knowledge-items-failed', scope: 'knowledge', error: e); return []; diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 00b6f48..9b00270 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -13,6 +13,9 @@ import '../models/user.dart'; import '../models/model.dart'; import '../models/conversation.dart'; import '../models/chat_message.dart'; +import '../models/file_info.dart'; +import '../models/knowledge_base.dart'; +import '../models/prompt.dart'; import '../auth/api_auth_interceptor.dart'; import '../error/api_error_interceptor.dart'; // Tool-call details are parsed in the UI layer to render collapsible blocks @@ -20,11 +23,10 @@ import 'persistent_streaming_service.dart'; import 'connectivity_service.dart'; import 'sse_stream_parser.dart'; import '../utils/debug_logger.dart'; -import '../utils/openwebui_source_parser.dart'; +import 'conversation_parsing.dart'; +import 'worker_manager.dart'; const bool _traceApiLogs = false; -const bool _traceConversationParsing = false; -const bool _traceFullChatParsing = false; void _traceApi(String message) { if (!_traceApiLogs) { @@ -36,6 +38,7 @@ void _traceApi(String message) { class ApiService { final Dio _dio; final ServerConfig serverConfig; + final WorkerManager _workerManager; late final ApiAuthInterceptor _authInterceptor; // Removed legacy websocket/socket.io fields @@ -51,21 +54,25 @@ class ApiService { // New callback for the unified auth state manager Future Function()? onTokenInvalidated; - ApiService({required this.serverConfig, String? authToken}) - : _dio = Dio( - BaseOptions( - baseUrl: serverConfig.url, - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 30), - followRedirects: true, - maxRedirects: 5, - validateStatus: (status) => status != null && status < 400, - // Add custom headers from server config - headers: serverConfig.customHeaders.isNotEmpty - ? Map.from(serverConfig.customHeaders) - : null, - ), - ) { + ApiService({ + required this.serverConfig, + required WorkerManager workerManager, + String? authToken, + }) : _dio = Dio( + BaseOptions( + baseUrl: serverConfig.url, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + followRedirects: true, + maxRedirects: 5, + validateStatus: (status) => status != null && status < 400, + // Add custom headers from server config + headers: serverConfig.customHeaders.isNotEmpty + ? Map.from(serverConfig.customHeaders) + : null, + ), + ), + _workerManager = workerManager { _configureSelfSignedSupport(); // Use API key from server config if provided and no explicit auth token @@ -486,102 +493,28 @@ class ApiService { }, ); - // Convert OpenWebUI chat format to our Conversation format - final conversations = []; - final pinnedIds = {}; - final archivedIds = {}; - - // Process pinned conversations first - for (final chatData in pinnedChatList) { - try { - final conversation = _parseOpenWebUIChat(chatData); - // Create a new conversation instance with pinned=true - final pinnedConversation = conversation.copyWith(pinned: true); - conversations.add(pinnedConversation); - pinnedIds.add(conversation.id); - } catch (e) { - DebugLogger.error( - 'parse-pinned-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, + final parsedJson = await _workerManager + .schedule, List>>( + parseConversationSummariesWorker, + { + 'pinned': pinnedChatList, + 'archived': archivedChatList, + 'regular': regularChatList, + }, + debugLabel: 'parse_conversation_list', ); - } - } - // Process archived conversations - for (final chatData in archivedChatList) { - try { - final conversation = _parseOpenWebUIChat(chatData); - // Create a new conversation instance with archived=true - final archivedConversation = conversation.copyWith(archived: true); - conversations.add(archivedConversation); - archivedIds.add(conversation.id); - } catch (e) { - DebugLogger.error( - 'parse-archived-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, - ); - } - } - - // Process regular conversations (excluding pinned and archived ones) - var loggedSampleChat = false; - for (final chatData in regularChatList) { - try { - // Debug: Check if conversation has folder_id in raw data - if (chatData.containsKey('folder_id') && - chatData['folder_id'] != null) { - DebugLogger.log( - 'folder-ref', - scope: 'api/conversations', - data: { - 'conversationId': chatData['id'], - 'folderId': chatData['folder_id'], - }, - ); - } - - if (!loggedSampleChat && _traceConversationParsing) { - loggedSampleChat = true; - DebugLogger.log( - 'sample-keys', - scope: 'api/conversations', - data: {'keys': chatData.keys.take(6).toList()}, - ); - DebugLogger.log( - 'sample-data', - scope: 'api/conversations', - data: {'preview': chatData.toString()}, - ); - } - - final conversation = _parseOpenWebUIChat(chatData); - // Only add if not already added as pinned or archived - if (!pinnedIds.contains(conversation.id) && - !archivedIds.contains(conversation.id)) { - conversations.add(conversation); - } - } catch (e) { - DebugLogger.error( - 'parse-regular-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, - ); - // Continue with other chats even if one fails - } - } + final conversations = parsedJson + .map((json) => Conversation.fromJson(json)) + .toList(growable: false); DebugLogger.log( 'parse-complete', scope: 'api/conversations', data: { 'total': conversations.length, - 'pinned': pinnedIds.length, - 'archived': archivedIds.length, + 'pinned': conversations.where((c) => c.pinned).length, + 'archived': conversations.where((c) => c.archived).length, }, ); return conversations; @@ -619,845 +552,26 @@ class ApiService { return []; } - // Helper method to safely parse timestamps - DateTime _parseTimestamp(dynamic timestamp) { - if (timestamp == null) return DateTime.now(); - - if (timestamp is int) { - // OpenWebUI uses Unix timestamps in seconds - // Check if it's already in milliseconds (13 digits) or seconds (10 digits) - final timestampMs = timestamp > 1000000000000 - ? timestamp - : timestamp * 1000; - return DateTime.fromMillisecondsSinceEpoch(timestampMs); - } - - if (timestamp is String) { - final parsed = int.tryParse(timestamp); - if (parsed != null) { - final timestampMs = parsed > 1000000000000 ? parsed : parsed * 1000; - return DateTime.fromMillisecondsSinceEpoch(timestampMs); - } - } - - return DateTime.now(); // Fallback to current time - } - // Parse OpenWebUI chat format to our Conversation format - Conversation _parseOpenWebUIChat(Map chatData) { - // OpenWebUI ChatTitleIdResponse format: - // { - // "id": "string", - // "title": "string", - // "updated_at": integer (timestamp), - // "created_at": integer (timestamp), - // "pinned": boolean (optional), - // "archived": boolean (optional), - // "share_id": string (optional), - // "folder_id": string (optional) - // } - - final id = chatData['id'] as String; - final title = chatData['title'] as String; - - // Safely parse timestamps with validation - // Try both snake_case and camelCase field names - final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; - final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; - - final updatedAt = _parseTimestamp(updatedAtRaw); - final createdAt = _parseTimestamp(createdAtRaw); - - // Parse additional OpenWebUI fields - // The API response might not include these fields, so we need to handle them safely - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; - final shareId = chatData['share_id'] as String?; - final folderId = chatData['folder_id'] as String?; - - // Debug logging for folder assignment - if (_traceConversationParsing && folderId != null) { - final idPreview = id.length > 8 ? id.substring(0, 8) : id; - DebugLogger.log( - 'folder-ref', - scope: 'api/conversations', - data: {'conversationId': idPreview, 'folderId': folderId}, - ); - } - - if (_traceConversationParsing) { - DebugLogger.log( - 'parsed', - scope: 'api/conversations', - data: {'id': id, 'pinned': pinned, 'archived': archived}, - ); - } - - String? systemPrompt; - final chatObject = chatData['chat'] as Map?; - if (chatObject != null) { - final systemValue = chatObject['system']; - if (systemValue is String && systemValue.trim().isNotEmpty) { - systemPrompt = systemValue; - } - } else if (chatData['system'] is String) { - final systemValue = (chatData['system'] as String).trim(); - if (systemValue.isNotEmpty) systemPrompt = systemValue; - } - - // For the list endpoint, we don't get the full chat messages - // We'll need to fetch individual chats later if needed - return Conversation( - id: id, - title: title, - createdAt: createdAt, - updatedAt: updatedAt, - systemPrompt: systemPrompt, - pinned: pinned, - archived: archived, - shareId: shareId, - folderId: folderId, - messages: [], // Empty for now, will be loaded when chat is opened - ); - } - Future getConversation(String id) async { DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id}); final response = await _dio.get('/api/v1/chats/$id'); DebugLogger.log('fetch-ok', scope: 'api/chat'); - // Parse OpenWebUI ChatResponse format - final chatData = response.data as Map; - return _parseFullOpenWebUIChat(chatData); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // Parse full OpenWebUI chat with messages - Conversation _parseFullOpenWebUIChat(Map chatData) { - if (_traceFullChatParsing) { - DebugLogger.log( - 'parse-full', - scope: 'api/chat', - data: {'keys': chatData.keys.take(8).toList()}, - ); - } - - final id = chatData['id'] as String; - final title = chatData['title'] as String; - - if (_traceFullChatParsing) { - DebugLogger.log( - 'chat-meta', - scope: 'api/chat', - data: {'id': id, 'title': title}, - ); - } - - // Safely parse timestamps with validation - final updatedAt = _parseTimestamp(chatData['updated_at']); - final createdAt = _parseTimestamp(chatData['created_at']); - - // Parse additional OpenWebUI fields - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; - final shareId = chatData['share_id'] as String?; - final folderId = chatData['folder_id'] as String?; - - // Parse messages from the 'chat' object or top-level messages - final chatObject = chatData['chat'] as Map?; - String? systemPrompt; - if (chatObject != null) { - final systemValue = chatObject['system']; - if (systemValue is String && systemValue.trim().isNotEmpty) { - systemPrompt = systemValue; - } - } else if (chatData['system'] is String) { - final systemValue = (chatData['system'] as String).trim(); - if (systemValue.isNotEmpty) systemPrompt = systemValue; - } - final messages = []; - - // Extract model from chat.models array - String? model; - if (chatObject != null && chatObject['models'] != null) { - final models = chatObject['models'] as List?; - if (models != null && models.isNotEmpty) { - model = models.first as String; - if (_traceFullChatParsing) { - DebugLogger.log( - 'model', - scope: 'api/chat', - data: {'id': id, 'model': model}, - ); - } - } - } - - // Try multiple locations for messages - prefer history-based ordering like Open‑WebUI - List? messagesList; - Map? historyMessagesMap; - - if (chatObject != null) { - // Prefer history.messages with currentId to reconstruct the selected branch - final history = chatObject['history'] as Map?; - if (history != null && history['messages'] is Map) { - historyMessagesMap = history['messages'] as Map; - - // Reconstruct ordered list using parent chain up to currentId - final currentId = history['currentId']?.toString(); - if (currentId != null && currentId.isNotEmpty) { - messagesList = _buildMessagesListFromHistory(history); - if (_traceFullChatParsing) { - DebugLogger.log( - 'history-chain', - scope: 'api/chat', - data: { - 'id': id, - 'count': messagesList.length, - 'currentId': currentId, - }, - ); - } - } - } - - // Fallback to chat.messages (list format) if history is missing or empty - if (((messagesList?.isEmpty ?? true)) && chatObject['messages'] != null) { - messagesList = chatObject['messages'] as List; - if (_traceFullChatParsing) { - DebugLogger.log( - 'messages-fallback', - scope: 'api/chat', - data: {'id': id, 'count': messagesList.length}, - ); - } - } - } else if (chatData['messages'] != null) { - messagesList = chatData['messages'] as List; - if (_traceFullChatParsing) { - DebugLogger.log( - 'messages-top-level', - scope: 'api/chat', - data: {'id': id, 'count': messagesList.length}, - ); - } - } - - // Parse messages from list format only (avoiding duplication) - if (messagesList != null) { - for (int idx = 0; idx < messagesList.length; idx++) { - final msgData = messagesList[idx] as Map; - try { - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-parse', - scope: 'api/chat', - data: { - 'chatId': id, - 'messageId': msgData['id'], - 'role': msgData['role'], - 'contentLen': msgData['content']?.toString().length ?? 0, - }, - ); - } - - // If this assistant message includes tool_calls, merge following tool results - final historyMsg = historyMessagesMap != null - ? (historyMessagesMap[msgData['id']] as Map?) - : null; - - final toolCalls = (msgData['tool_calls'] is List) - ? (msgData['tool_calls'] as List) - : (historyMsg != null && historyMsg['tool_calls'] is List) - ? (historyMsg['tool_calls'] as List) - : null; - - if ((msgData['role']?.toString() == 'assistant') && - toolCalls is List) { - // Collect subsequent tool results associated with this assistant turn - final List> results = []; - int j = idx + 1; - while (j < messagesList.length) { - final next = messagesList[j] as Map; - if ((next['role']?.toString() ?? '') != 'tool') break; - final toolCallId = next['tool_call_id']?.toString(); - final resContent = next['content']; - final resFiles = next['files']; - results.add({ - 'tool_call_id': toolCallId, - 'content': resContent, - if (resFiles != null) 'files': resFiles, - }); - j++; - } - - // Synthesize content from tool_calls and results - final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( - toolCalls, - results, - ); - - final mergedAssistant = Map.from(msgData); - mergedAssistant['content'] = synthesized; - - final message = _parseOpenWebUIMessage( - mergedAssistant, - historyMsg: historyMsg, - ); - messages.add(message); - - // Skip the tool messages we just merged - idx = j - 1; - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-tool-call', - scope: 'api/chat', - data: {'chatId': id, 'messageId': message.id}, - ); - } - continue; - } - - // Default path: parse message as-is - var message = _parseOpenWebUIMessage(msgData, historyMsg: historyMsg); - - // Attach server-persisted variants (siblings) as versions for assistant - if (message.role == 'assistant' && historyMessagesMap != null) { - try { - final parentId = historyMsg?['parentId']?.toString(); - if (parentId != null && parentId.isNotEmpty) { - final parent = - historyMessagesMap[parentId] as Map?; - final children = parent != null && parent['childrenIds'] is List - ? (parent['childrenIds'] as List) - .map((e) => e.toString()) - .toList() - : const []; - final versions = []; - - for (final cid in children) { - if (cid == message.id) continue; // skip current assistant - final sibling = historyMessagesMap[cid]; - if (sibling is Map) { - final role = (sibling['role'] ?? '').toString(); - if (role != 'assistant') continue; - // Build a ChatMessage from sibling for consistent parsing - final siblingData = Map.from(sibling); - siblingData['id'] = cid; - final parsed = _parseOpenWebUIMessage( - siblingData, - historyMsg: sibling, - ); - versions.add( - ChatMessageVersion( - id: parsed.id, - content: parsed.content, - timestamp: parsed.timestamp, - model: parsed.model, - files: parsed.files, - sources: parsed.sources, - followUps: parsed.followUps, - codeExecutions: parsed.codeExecutions, - usage: parsed.usage, - ), - ); - } - } - - if (versions.isNotEmpty) { - message = message.copyWith(versions: versions); - } - } - } catch (_) { - // Best-effort: ignore variants if parsing fails - } - } - messages.add(message); - if (_traceFullChatParsing) { - DebugLogger.log( - 'message', - scope: 'api/chat', - data: { - 'chatId': id, - 'messageId': message.id, - 'role': message.role, - }, - ); - } - } catch (e) { - DebugLogger.error( - 'message-parse-failed', - scope: 'api/chat', - error: e, - data: {'chatId': id, 'messageId': msgData['id']}, - ); - } - } - } - - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-count', - scope: 'api/chat', - data: {'chatId': id, 'count': messages.length}, - ); - } - - return Conversation( - id: id, - title: title, - createdAt: createdAt, - updatedAt: updatedAt, - model: model, - systemPrompt: systemPrompt, - pinned: pinned, - archived: archived, - shareId: shareId, - folderId: folderId, - messages: messages, - ); - } - // Parse OpenWebUI message format to our ChatMessage format - ChatMessage _parseOpenWebUIMessage( - Map msgData, { - Map? historyMsg, - }) { - // OpenWebUI message format may vary, but typically: - // { "role": "user|assistant", "content": "text", ... } - - // Create a single UUID instance to reuse - const uuid = Uuid(); - - // Prefer richer content from history entry if present - dynamic content = msgData['content']; - if ((content == null || (content is String && content.isEmpty)) && - historyMsg != null && - historyMsg['content'] != null) { - content = historyMsg['content']; - } - String contentString; - if (content is List) { - // Concatenate all text fragments in order (Open‑WebUI may split long text) - final buffer = StringBuffer(); - for (final item in content) { - if (item is Map && item['type'] == 'text') { - final t = item['text']?.toString(); - if (t != null && t.isNotEmpty) buffer.write(t); - } - } - contentString = buffer.toString(); - if (contentString.trim().isEmpty) { - // Fallback: look for tool-related entries in the array and synthesize details blocks - final synthesized = _synthesizeToolDetailsFromContentArray(content); - if (synthesized.isNotEmpty) { - contentString = synthesized; - } - } - } else { - contentString = (content as String?) ?? ''; - } - - // Prefer longer content from history if available (guards against truncated previews) - if (historyMsg != null) { - final histContent = historyMsg['content']; - if (histContent is String && histContent.length > contentString.length) { - contentString = histContent; - } else if (histContent is List) { - final buf = StringBuffer(); - for (final item in histContent) { - if (item is Map && item['type'] == 'text') { - final t = item['text']?.toString(); - if (t != null && t.isNotEmpty) buf.write(t); - } - } - final combined = buf.toString(); - if (combined.length > contentString.length) { - contentString = combined; - } - } - } - - // Final fallback: some servers store tool calls under tool_calls instead of content - final toolCallsList = (msgData['tool_calls'] is List) - ? (msgData['tool_calls'] as List) - : (historyMsg != null && historyMsg['tool_calls'] is List) - ? (historyMsg['tool_calls'] as List) - : null; - if (contentString.trim().isEmpty && toolCallsList is List) { - final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList); - if (synthesized.isNotEmpty) { - contentString = synthesized; - } - } - - // Determine role based on available fields - String role; - if (msgData['role'] != null) { - role = msgData['role'] as String; - } else if (msgData['model'] != null) { - // Messages with model field are typically assistant messages - role = 'assistant'; - } else { - // Default to user if no role or model - role = 'user'; - } - - // Parse attachments and generated images from 'files' field - List? attachmentIds; - List>? files; - - final effectiveFiles = msgData['files'] ?? historyMsg?['files']; - if (effectiveFiles != null) { - final filesList = effectiveFiles as List; - - // Handle different file formats from OpenWebUI - final userAttachments = []; - final allFiles = >[]; - - for (final file in filesList) { - if (file is Map) { - if (file['file_id'] != null) { - // User uploaded file with file_id (legacy format) - userAttachments.add(file['file_id'] as String); - } else if (file['type'] != null && file['url'] != null) { - // File with type and url (OpenWebUI format) - final fileMap = { - 'type': file['type'], - 'url': file['url'], - }; - - // Add optional fields if present - if (file['name'] != null) fileMap['name'] = file['name']; - if (file['size'] != null) fileMap['size'] = file['size']; - - allFiles.add(fileMap); - - // If this is a user-uploaded file (URL contains file ID), also extract the ID - final url = file['url'] as String; - if (url.contains('/api/v1/files/') && url.contains('/content')) { - final fileIdMatch = RegExp( - r'/api/v1/files/([^/]+)/content', - ).firstMatch(url); - if (fileIdMatch != null) { - userAttachments.add(fileIdMatch.group(1)!); - } - } - } - } - } - - attachmentIds = userAttachments.isNotEmpty ? userAttachments : null; - files = allFiles.isNotEmpty ? allFiles : null; - } - - final dynamic statusRaw = - historyMsg != null && historyMsg.containsKey('statusHistory') - ? historyMsg['statusHistory'] - : msgData['statusHistory']; - final statusHistory = _parseStatusHistoryField(statusRaw); - - final dynamic followUpsRaw = - historyMsg != null && historyMsg.containsKey('followUps') - ? historyMsg['followUps'] - : msgData['followUps'] ?? msgData['follow_ups']; - final followUps = _parseFollowUpsField(followUpsRaw); - - final dynamic codeExecRaw = historyMsg != null - ? (historyMsg['code_executions'] ?? historyMsg['codeExecutions']) - : (msgData['code_executions'] ?? msgData['codeExecutions']); - final codeExecutions = _parseCodeExecutionsField(codeExecRaw); - - final dynamic sourcesRaw = - historyMsg != null && historyMsg.containsKey('sources') - ? historyMsg['sources'] - : msgData['sources']; - final sources = _parseSourcesField(sourcesRaw); - - return ChatMessage( - id: msgData['id']?.toString() ?? uuid.v4(), - role: role, - content: contentString, - timestamp: _parseTimestamp(msgData['timestamp']), - model: msgData['model'] as String?, - attachmentIds: attachmentIds, - files: files, - statusHistory: statusHistory, - followUps: followUps, - codeExecutions: codeExecutions, - sources: sources, - ); - } - // Build ordered messages list from Open‑WebUI history using parent chain to currentId - List> _buildMessagesListFromHistory( - Map history, - ) { - final messagesMap = history['messages'] as Map?; - final currentId = history['currentId']?.toString(); - - if (messagesMap == null || currentId == null) return []; - - List> buildChain(String? id) { - if (id == null) return []; - final raw = messagesMap[id]; - if (raw == null) return []; - final msg = Map.from(raw as Map); - msg['id'] = id; // ensure id present - final parentId = msg['parentId']?.toString(); - if (parentId != null && parentId.isNotEmpty) { - return [...buildChain(parentId), msg]; - } - return [msg]; - } - - return buildChain(currentId); - } - // ===== Helpers to synthesize tool-call details blocks for UI parsing ===== - String _escapeHtmlAttr(String s) { - return s - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll('<', '<') - .replaceAll('>', '>'); - } - - String _jsonStringify(dynamic v) { - try { - return jsonEncode(v); - } catch (_) { - return v?.toString() ?? ''; - } - } - - String _synthesizeToolDetailsFromToolCalls(List toolCalls) { - final buf = StringBuffer(); - for (final c in toolCalls) { - if (c is! Map) continue; - final func = c['function'] as Map?; - final name = - (func != null ? func['name'] : c['name'])?.toString() ?? 'tool'; - final id = - (c['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final done = (c['done']?.toString() ?? 'true'); - final argsRaw = func != null ? func['arguments'] : c['arguments']; - final resRaw = - c['result'] ?? c['output'] ?? (func != null ? func['result'] : null); - final argsStr = _jsonStringify(argsRaw); - final resStr = resRaw != null ? _jsonStringify(resRaw) : null; - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write(' done="${_escapeHtmlAttr(done)}"') - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null && resStr.isNotEmpty) { - attrs.write(' result="${_escapeHtmlAttr(resStr)}"'); - } - buf.writeln( - '
Tool Executed', - ); - buf.writeln('
'); - } - return buf.toString().trim(); - } - - String _synthesizeToolDetailsFromToolCallsWithResults( - List toolCalls, - List> results, - ) { - final buf = StringBuffer(); - Map> resultsMap = {}; - for (final r in results) { - final id = r['tool_call_id']?.toString(); - if (id != null) resultsMap[id] = r; - } - - for (final c in toolCalls) { - if (c is! Map) continue; - final func = c['function'] as Map?; - final name = - (func != null ? func['name'] : c['name'])?.toString() ?? 'tool'; - final id = - (c['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final argsRaw = func != null ? func['arguments'] : c['arguments']; - final argsStr = _jsonStringify(argsRaw); - final resultEntry = resultsMap[id]; - final resRaw = resultEntry != null ? resultEntry['content'] : null; - final filesRaw = resultEntry != null ? resultEntry['files'] : null; - final resStr = resRaw != null ? _jsonStringify(resRaw) : null; - final filesStr = filesRaw != null ? _jsonStringify(filesRaw) : null; - - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write( - ' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"', - ) - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null && resStr.isNotEmpty) { - attrs.write(' result="${_escapeHtmlAttr(resStr)}"'); - } - if (filesStr != null && filesStr.isNotEmpty) { - attrs.write(' files="${_escapeHtmlAttr(filesStr)}"'); - } - - buf.writeln( - '
${resultEntry != null ? 'Tool Executed' : 'Executing...'}', - ); - buf.writeln('
'); - } - return buf.toString().trim(); - } - - String _synthesizeToolDetailsFromContentArray(List content) { - final buf = StringBuffer(); - for (final item in content) { - if (item is! Map) continue; - final type = item['type']?.toString(); - if (type == null) continue; - // OpenWebUI content-blocks shape: { type: 'tool_calls', content: [...], results: [...] } - if (type == 'tool_calls') { - final calls = (item['content'] is List) - ? (item['content'] as List) - : []; - final results = >[]; - if (item['results'] is List) { - for (final r in (item['results'] as List)) { - if (r is Map) results.add(r); - } - } - final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( - calls, - results, - ); - if (synthesized.isNotEmpty) buf.writeln(synthesized); - continue; - } - - // Heuristics: handle other variants (single tool/function call entries) - if (type == 'tool_call' || type == 'function_call') { - final name = (item['name'] ?? item['tool'] ?? 'tool').toString(); - final id = - (item['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final argsStr = _jsonStringify(item['arguments'] ?? item['args']); - final resStr = item['result'] ?? item['output'] ?? item['response']; - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write( - ' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"', - ) - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null) { - final r = _jsonStringify(resStr); - if (r.isNotEmpty) attrs.write(' result="${_escapeHtmlAttr(r)}"'); - } - buf.writeln( - '
${resStr != null ? 'Tool Executed' : 'Executing...'}', - ); - buf.writeln('
'); - } - } - return buf.toString().trim(); - } - - List _parseStatusHistoryField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((entry) { - try { - // Convert Map to Map safely - final Map statusMap = {}; - entry.forEach((key, value) { - statusMap[key.toString()] = value; - }); - final statusUpdate = ChatStatusUpdate.fromJson(statusMap); - - // Debug log to help diagnose template issues - if (statusUpdate.description?.contains('{{count}}') == true) { - DebugLogger.log( - 'template-placeholder-found', - scope: 'api/chat', - data: { - 'description': statusUpdate.description, - 'count': statusUpdate.count, - 'urls': statusUpdate.urls.length, - 'items': statusUpdate.items.length, - 'action': statusUpdate.action, - }, - ); - } - - return statusUpdate; - } catch (e) { - // Log the error and skip this entry - DebugLogger.log( - 'status-parse-error', - scope: 'api/chat', - data: {'error': e.toString(), 'entry': entry.toString()}, - ); - return null; - } - }) - .where((item) => item != null) - .cast() - .toList(growable: false); - } - return const []; - } - - List _parseFollowUpsField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((value) => value?.toString().trim() ?? '') - .where((value) => value.isNotEmpty) - .toList(growable: false); - } - if (raw is String && raw.trim().isNotEmpty) { - return [raw.trim()]; - } - return const []; - } - - List _parseCodeExecutionsField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((entry) { - try { - // Convert Map to Map safely - final Map execMap = {}; - entry.forEach((key, value) { - execMap[key.toString()] = value; - }); - return ChatCodeExecution.fromJson(execMap); - } catch (e) { - // Log the error and skip this entry - DebugLogger.log( - 'code-exec-parse-error', - scope: 'api/chat', - data: {'error': e.toString(), 'entry': entry.toString()}, - ); - return null; - } - }) - .where((item) => item != null) - .cast() - .toList(growable: false); - } - return const []; - } - List>? _sanitizeFilesForWebUI( List>? files, ) { @@ -1478,14 +592,6 @@ class ApiService { return sanitized.isNotEmpty ? sanitized : null; } - List _parseSourcesField(dynamic raw) { - try { - return parseOpenWebUISourceList(raw); - } catch (_) { - return const []; - } - } - // Create new conversation using OpenWebUI API Future createConversation({ required String title, @@ -1584,9 +690,14 @@ class ApiService { ); DebugLogger.log('create-ok', scope: 'api/conversation'); - // Parse the response - final responseData = response.data as Map; - return _parseFullOpenWebUIChat(responseData); + final responseData = response.data; + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': responseData}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // Sync conversation messages to ensure WebUI can load conversation history @@ -1770,7 +881,13 @@ class ApiService { Future cloneConversation(String id) async { _traceApi('Cloning conversation: $id'); final response = await _dio.post('/api/v1/chats/$id/clone'); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // User Settings @@ -1797,6 +914,38 @@ class ApiService { return []; } + Future> _parseConversationSummaryList( + List regular, { + required String debugLabel, + }) async { + final payload = { + 'regular': List.from(regular), + 'pinned': const [], + 'archived': const [], + }; + final parsed = await _workerManager + .schedule, List>>( + parseConversationSummariesWorker, + payload, + debugLabel: debugLabel, + ); + return parsed + .map((json) => Conversation.fromJson(json)) + .toList(growable: false); + } + + Future>> _normalizeList( + List raw, { + required String debugLabel, + }) { + return _workerManager + .schedule, List>>( + _normalizeMapListWorker, + {'list': raw}, + debugLabel: debugLabel, + ); + } + // Tools - Check available tools on server Future>> getAvailableTools() async { _traceApi('Fetching available tools'); @@ -1891,7 +1040,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/folder/$folderId'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return _parseConversationSummaryList( + data, + debugLabel: 'parse_folder_$folderId', + ); } return []; } @@ -1935,7 +1087,7 @@ class ApiService { final response = await _dio.get('/api/v1/chats/tags/$tag'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return _parseConversationSummaryList(data, debugLabel: 'parse_tag_$tag'); } return []; } @@ -1980,18 +1132,22 @@ class ApiService { return response.data as Map; } - Future>> getUserFiles() async { + Future> getUserFiles() async { _traceApi('Fetching user files'); final response = await _dio.get('/api/v1/files/'); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_file_list', + ); + return normalized.map(FileInfo.fromJson).toList(growable: false); } - return []; + return const []; } // Enhanced File Operations - Future>> searchFiles({ + Future> searchFiles({ String? query, String? contentType, int? limit, @@ -2010,19 +1166,31 @@ class ApiService { ); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_file_search', + ); + return normalized + .map(FileInfo.fromJson) + .toList(growable: false); } - return []; + return const []; } - Future>> getAllFiles() async { + Future> getAllFiles() async { _traceApi('Fetching all files (admin)'); final response = await _dio.get('/api/v1/files/all'); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_file_all', + ); + return normalized + .map(FileInfo.fromJson) + .toList(growable: false); } - return []; + return const []; } Future uploadFileWithProgress( @@ -2125,14 +1293,18 @@ class ApiService { } // Knowledge Base - Future>> getKnowledgeBases() async { + Future> getKnowledgeBases() async { _traceApi('Fetching knowledge bases'); final response = await _dio.get('/api/v1/knowledge/'); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_knowledge_bases', + ); + return normalized.map(KnowledgeBase.fromJson).toList(growable: false); } - return []; + return const []; } Future> createKnowledgeBase({ @@ -2167,16 +1339,20 @@ class ApiService { await _dio.delete('/api/v1/knowledge/$id'); } - Future>> getKnowledgeBaseItems( + Future> getKnowledgeBaseItems( String knowledgeBaseId, ) async { _traceApi('Fetching knowledge base items: $knowledgeBaseId'); final response = await _dio.get('/api/v1/knowledge/$knowledgeBaseId/items'); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_kb_items', + ); + return normalized.map(KnowledgeBaseItem.fromJson).toList(growable: false); } - return []; + return const []; } Future> addKnowledgeBaseItem( @@ -2423,10 +1599,10 @@ class ApiService { if (data is Map) { final voices = data['voices']; if (voices is List) { - return voices - .whereType() - .map((e) => e.cast()) - .toList(); + return _normalizeList( + voices, + debugLabel: 'parse_voice_list', + ); } } if (data is List) { @@ -2529,7 +1705,10 @@ class ApiService { final response = await _dio.get('/api/v1/images/models'); final data = response.data; if (data is List) { - return data.cast>(); + return _normalizeList( + data, + debugLabel: 'parse_image_models', + ); } return []; } @@ -2571,14 +1750,21 @@ class ApiService { } // Prompts - Future>> getPrompts() async { + Future> getPrompts() async { _traceApi('Fetching prompts'); final response = await _dio.get('/api/v1/prompts/'); final data = response.data; if (data is List) { - return data.cast>(); + final normalized = await _normalizeList( + data, + debugLabel: 'parse_prompts', + ); + return normalized + .map(Prompt.fromJson) + .where((prompt) => prompt.command.isNotEmpty) + .toList(growable: false); } - return []; + return const []; } // Permissions & Features @@ -2998,7 +2184,10 @@ class ApiService { final response = await _dio.get('/api/v1/channels/$channelId/chats'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3615,8 +2804,11 @@ class ApiService { '/api/v1/chats/search', queryParameters: {'q': query}, ); - final results = response.data as List; - return results.map((c) => Conversation.fromJson(c)).toList(); + final results = response.data; + if (results is List) { + return _parseConversationSummaryList(results, debugLabel: 'parse_search'); + } + return []; } // Debug method to test API endpoints @@ -3792,7 +2984,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/pinned'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3810,7 +3005,10 @@ class ApiService { ); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3854,23 +3052,23 @@ class ApiService { ); final data = response.data; // The endpoint can return a List[ChatTitleIdResponse] or a map. - // Normalize to a List using our safe parser. + // Normalize to a List using our isolate parser. if (data is List) { - return data - .whereType>() - .map((e) => _parseOpenWebUIChat(e)) - .toList(); + return _parseConversationSummaryList( + data, + debugLabel: 'parse_search_direct', + ); } if (data is Map) { final list = (data['conversations'] ?? data['items'] ?? data['results']); if (list is List) { - return list - .whereType>() - .map((e) => _parseOpenWebUIChat(e)) - .toList(); + return _parseConversationSummaryList( + list, + debugLabel: 'parse_search_wrapped', + ); } } - return []; + return const []; } /// Search within messages content (capability-safe) @@ -3925,19 +3123,25 @@ class ApiService { final data = response.data; if (data is List) { - return data.whereType>().toList(); + return _normalizeList( + data, + debugLabel: 'parse_message_search', + ); } if (data is Map) { final list = (data['items'] ?? data['results'] ?? data['messages']); if (list is List) { - return list.whereType>().toList(); + return _normalizeList( + list, + debugLabel: 'parse_message_search_wrapped', + ); } } - return []; + return const []; } on DioException catch (e) { // On any transport or other error, degrade gracefully without surfacing _traceApi('messages search request failed gracefully: ${e.type}'); - return []; + return const []; } } @@ -3967,7 +3171,13 @@ class ApiService { '/api/v1/chats/$chatId/duplicate', data: {if (title != null) 'title': title}, ); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } /// Get recent chats with activity @@ -3982,7 +3192,13 @@ class ApiService { ); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data + .whereType>() + .map( + (chatData) => + Conversation.fromJson(parseConversationSummary(chatData)), + ) + .toList(); } return []; } @@ -4094,10 +3310,32 @@ class ApiService { if (title != null) 'title': title, }, ); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // ==================== END ADVANCED CHAT FEATURES ==================== // Legacy streaming wrapper methods removed } + +List> _normalizeMapListWorker( + Map payload, +) { + final raw = payload['list']; + if (raw is! List) { + return const >[]; + } + final normalized = >[]; + for (final entry in raw) { + if (entry is Map) { + normalized.add(Map.from(entry)); + } + } + return normalized; +} diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart new file mode 100644 index 0000000..6cc8ee6 --- /dev/null +++ b/lib/core/services/conversation_parsing.dart @@ -0,0 +1,696 @@ +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +/// Utilities for converting OpenWebUI conversation payloads into JSON maps +/// that match the app's `Conversation` / `ChatMessage` schemas. All helpers +/// here are isolate-safe (they only work with primitive JSON types) so they +/// can be executed inside a background worker. + +const _uuid = Uuid(); + +Map parseConversationSummary(Map chatData) { + final id = (chatData['id'] ?? '').toString(); + final title = _stringOr(chatData['title'], 'Chat'); + + final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; + final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; + + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id']?.toString(); + final folderId = chatData['folder_id']?.toString(); + + String? systemPrompt; + final chatObject = chatData['chat']; + if (chatObject is Map) { + final value = chatObject['system']; + if (value is String && value.trim().isNotEmpty) { + systemPrompt = value; + } + } else if (chatData['system'] is String) { + final value = (chatData['system'] as String).trim(); + if (value.isNotEmpty) systemPrompt = value; + } + + return { + 'id': id, + 'title': title, + 'createdAt': _parseTimestamp(createdAtRaw).toIso8601String(), + 'updatedAt': _parseTimestamp(updatedAtRaw).toIso8601String(), + 'model': chatData['model']?.toString(), + 'systemPrompt': systemPrompt, + 'messages': const >[], + 'metadata': _coerceJsonMap(chatData['metadata']), + 'pinned': pinned, + 'archived': archived, + 'shareId': shareId, + 'folderId': folderId, + 'tags': _coerceStringList(chatData['tags']), + }; +} + +Map parseFullConversation(Map chatData) { + final id = (chatData['id'] ?? '').toString(); + final title = _stringOr(chatData['title'], 'Chat'); + + final updatedAt = _parseTimestamp( + chatData['updated_at'] ?? chatData['updatedAt'], + ); + final createdAt = _parseTimestamp( + chatData['created_at'] ?? chatData['createdAt'], + ); + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id']?.toString(); + final folderId = chatData['folder_id']?.toString(); + + String? systemPrompt; + final chatObject = chatData['chat']; + if (chatObject is Map) { + final value = chatObject['system']; + if (value is String && value.trim().isNotEmpty) { + systemPrompt = value; + } + } else if (chatData['system'] is String) { + final value = (chatData['system'] as String).trim(); + if (value.isNotEmpty) systemPrompt = value; + } + + String? model; + Map? historyMessagesMap; + List>? messagesList; + + if (chatObject is Map) { + final history = chatObject['history']; + if (history is Map) { + if (history['messages'] is Map) { + historyMessagesMap = history['messages'] as Map; + messagesList = _buildMessagesListFromHistory(history); + } + } + + if ((messagesList == null || messagesList.isEmpty) && + chatObject['messages'] is List) { + messagesList = (chatObject['messages'] as List) + .whereType>() + .toList(); + } + + final models = chatObject['models']; + if (models is List && models.isNotEmpty) { + model = models.first?.toString(); + } + } + + if ((messagesList == null || messagesList.isEmpty) && + chatData['messages'] is List) { + messagesList = (chatData['messages'] as List) + .whereType>() + .toList(); + } + + final messages = >[]; + if (messagesList != null) { + var index = 0; + while (index < messagesList.length) { + final msgData = Map.from(messagesList[index]); + final historyMsg = historyMessagesMap != null + ? (historyMessagesMap[msgData['id']] as Map?) + : null; + + final toolCalls = _extractToolCalls(msgData, historyMsg); + if ((msgData['role']?.toString() ?? '') == 'assistant' && + toolCalls != null) { + final results = >[]; + var j = index + 1; + while (j < messagesList.length) { + final nextRaw = messagesList[j]; + if ((nextRaw['role']?.toString() ?? '') != 'tool') break; + results.add({ + 'tool_call_id': nextRaw['tool_call_id']?.toString(), + 'content': nextRaw['content'], + if (nextRaw.containsKey('files')) 'files': nextRaw['files'], + }); + j++; + } + + final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( + toolCalls, + results, + ); + final merged = Map.from(msgData); + if (synthesized.isNotEmpty) { + merged['content'] = synthesized; + } + + messages.add( + _parseOpenWebUIMessageToJson(merged, historyMsg: historyMsg), + ); + index = j; + continue; + } + + messages.add( + _parseOpenWebUIMessageToJson(msgData, historyMsg: historyMsg), + ); + index++; + } + } + + return { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'model': model, + 'systemPrompt': systemPrompt, + 'messages': messages, + 'metadata': _coerceJsonMap(chatData['metadata']), + 'pinned': pinned, + 'archived': archived, + 'shareId': shareId, + 'folderId': folderId, + 'tags': _coerceStringList(chatData['tags']), + }; +} + +List>? _extractToolCalls( + Map msgData, + Map? historyMsg, +) { + final toolCallsRaw = + msgData['tool_calls'] ?? + historyMsg?['tool_calls'] ?? + historyMsg?['toolCalls']; + if (toolCallsRaw is List) { + return toolCallsRaw.whereType().map(_coerceJsonMap).toList(); + } + return null; +} + +Map _parseOpenWebUIMessageToJson( + Map msgData, { + Map? historyMsg, +}) { + dynamic content = msgData['content']; + if ((content == null || (content is String && content.isEmpty)) && + historyMsg != null && + historyMsg['content'] != null) { + content = historyMsg['content']; + } + + var contentString = ''; + if (content is List) { + final buffer = StringBuffer(); + for (final entry in content) { + if (entry is Map && entry['type'] == 'text') { + final text = entry['text']?.toString(); + if (text != null && text.isNotEmpty) { + buffer.write(text); + } + } + } + contentString = buffer.toString(); + if (contentString.trim().isEmpty) { + final synthesized = _synthesizeToolDetailsFromContentArray(content); + if (synthesized.isNotEmpty) { + contentString = synthesized; + } + } + } else { + contentString = content?.toString() ?? ''; + } + + if (historyMsg != null) { + final histContent = historyMsg['content']; + if (histContent is String && histContent.length > contentString.length) { + contentString = histContent; + } else if (histContent is List) { + final buf = StringBuffer(); + for (final entry in histContent) { + if (entry is Map && entry['type'] == 'text') { + final text = entry['text']?.toString(); + if (text != null && text.isNotEmpty) { + buf.write(text); + } + } + } + final combined = buf.toString(); + if (combined.length > contentString.length) { + contentString = combined; + } + } + } + + final toolCallsList = _extractToolCalls(msgData, historyMsg); + if (contentString.trim().isEmpty && toolCallsList != null) { + final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList); + if (synthesized.isNotEmpty) { + contentString = synthesized; + } + } + + final role = _resolveRole(msgData); + + final effectiveFiles = msgData['files'] ?? historyMsg?['files']; + List? attachmentIds; + List>? files; + if (effectiveFiles is List) { + final attachments = []; + final allFiles = >[]; + for (final entry in effectiveFiles) { + if (entry is! Map) continue; + if (entry['file_id'] != null) { + attachments.add(entry['file_id'].toString()); + } else if (entry['type'] != null && entry['url'] != null) { + final fileMap = { + 'type': entry['type'], + 'url': entry['url'], + }; + if (entry['name'] != null) fileMap['name'] = entry['name']; + if (entry['size'] != null) fileMap['size'] = entry['size']; + allFiles.add(fileMap); + + final url = entry['url'].toString(); + final match = RegExp(r'/api/v1/files/([^/]+)/content').firstMatch(url); + if (match != null) { + attachments.add(match.group(1)!); + } + } + } + attachmentIds = attachments.isNotEmpty ? attachments : null; + files = allFiles.isNotEmpty ? allFiles : null; + } + + final statusHistoryRaw = + historyMsg != null && historyMsg.containsKey('statusHistory') + ? historyMsg['statusHistory'] + : msgData['statusHistory']; + final followUpsRaw = historyMsg != null && historyMsg.containsKey('followUps') + ? historyMsg['followUps'] + : msgData['followUps'] ?? msgData['follow_ups']; + final codeExecRaw = historyMsg != null + ? historyMsg['code_executions'] ?? historyMsg['codeExecutions'] + : msgData['code_executions'] ?? msgData['codeExecutions']; + final sourcesRaw = historyMsg != null && historyMsg.containsKey('sources') + ? historyMsg['sources'] + : msgData['sources']; + + return { + 'id': (msgData['id'] ?? _uuid.v4()).toString(), + 'role': role, + 'content': contentString, + 'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(), + 'model': msgData['model']?.toString(), + 'isStreaming': msgData['isStreaming'] as bool? ?? false, + if (attachmentIds != null) 'attachmentIds': attachmentIds, + if (files != null) 'files': files, + 'metadata': _coerceJsonMap(msgData['metadata']), + 'statusHistory': _parseStatusHistoryField(statusHistoryRaw), + 'followUps': _coerceStringList(followUpsRaw), + 'codeExecutions': _parseCodeExecutionsField(codeExecRaw), + 'sources': _parseSourcesField(sourcesRaw), + 'usage': _coerceJsonMap(msgData['usage']), + 'versions': const >[], + }; +} + +String _resolveRole(Map msgData) { + if (msgData['role'] != null) { + return msgData['role'].toString(); + } + if (msgData['model'] != null) { + return 'assistant'; + } + return 'user'; +} + +List> _buildMessagesListFromHistory( + Map history, +) { + final messagesMap = history['messages']; + final currentId = history['currentId']?.toString(); + if (messagesMap is! Map || currentId == null) { + return const []; + } + + List> buildChain(String? id) { + if (id == null) return const []; + final raw = messagesMap[id]; + if (raw is! Map) return const []; + final msg = _coerceJsonMap(raw); + msg['id'] = id; + final parentId = msg['parentId']?.toString(); + if (parentId != null && parentId.isNotEmpty) { + return [...buildChain(parentId), msg]; + } + return [msg]; + } + + return buildChain(currentId); +} + +DateTime _parseTimestamp(dynamic timestamp) { + if (timestamp == null) return DateTime.now(); + if (timestamp is int) { + final ts = timestamp > 1000000000000 ? timestamp : timestamp * 1000; + return DateTime.fromMillisecondsSinceEpoch(ts); + } + if (timestamp is String) { + final parsedInt = int.tryParse(timestamp); + if (parsedInt != null) { + final ts = parsedInt > 1000000000000 ? parsedInt : parsedInt * 1000; + return DateTime.fromMillisecondsSinceEpoch(ts); + } + return DateTime.tryParse(timestamp) ?? DateTime.now(); + } + if (timestamp is double) { + final ts = timestamp > 1000000000000 + ? timestamp.round() + : (timestamp * 1000).round(); + return DateTime.fromMillisecondsSinceEpoch(ts); + } + return DateTime.now(); +} + +List> _parseStatusHistoryField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((entry) => _coerceJsonMap(entry)) + .toList(growable: false); + } + return const >[]; +} + +List _coerceStringList(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((value) => value?.toString().trim() ?? '') + .where((value) => value.isNotEmpty) + .toList(growable: false); + } + if (raw is String && raw.trim().isNotEmpty) { + return [raw.trim()]; + } + return const []; +} + +List> _parseCodeExecutionsField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((entry) => _coerceJsonMap(entry)) + .toList(growable: false); + } + return const >[]; +} + +List> _parseSourcesField(dynamic raw) { + if (raw is List) { + return raw.whereType().map(_coerceJsonMap).toList(growable: false); + } + if (raw is Map) { + return [_coerceJsonMap(raw)]; + } + if (raw is String) { + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded.whereType().map(_coerceJsonMap).toList(); + } + } catch (_) {} + } + return const >[]; +} + +Map _coerceJsonMap(Object? value) { + if (value is Map) { + return value.map((key, v) => MapEntry(key.toString(), _coerceJsonValue(v))); + } + if (value is Map) { + final result = {}; + value.forEach((key, v) { + result[key.toString()] = _coerceJsonValue(v); + }); + return result; + } + return {}; +} + +dynamic _coerceJsonValue(dynamic value) { + if (value is Map) { + return _coerceJsonMap(value); + } + if (value is List) { + return value.map(_coerceJsonValue).toList(); + } + return value; +} + +String _stringOr(dynamic value, String fallback) { + if (value is String && value.isNotEmpty) { + return value; + } + return fallback; +} + +String _synthesizeToolDetailsFromToolCalls(List calls) { + final buffer = StringBuffer(); + for (final rawCall in calls) { + final call = Map.from(rawCall); + final function = call['function']; + final name = + (function is Map ? function['name'] : call['name'])?.toString() ?? + 'tool'; + final id = + (call['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final done = call['done']?.toString() ?? 'true'; + final argsRaw = function is Map ? function['arguments'] : call['arguments']; + final resRaw = + call['result'] ?? + call['output'] ?? + (function is Map ? function['result'] : null); + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write(' done="${_escapeHtmlAttr(done)}"') + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"'); + final resultStr = _jsonStringify(resRaw); + if (resultStr.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(resultStr)}"'); + } + buffer.writeln( + '
Tool Executed
', + ); + } + return buffer.toString().trim(); +} + +String _synthesizeToolDetailsFromToolCallsWithResults( + List calls, + List results, +) { + final buffer = StringBuffer(); + final resultsMap = >{}; + for (final rawResult in results) { + final result = Map.from(rawResult); + final id = result['tool_call_id']?.toString(); + if (id != null) { + resultsMap[id] = result; + } + } + + for (final rawCall in calls) { + final call = Map.from(rawCall); + final function = call['function']; + final name = + (function is Map ? function['name'] : call['name'])?.toString() ?? + 'tool'; + final id = + (call['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final argsRaw = function is Map ? function['arguments'] : call['arguments']; + final resultEntry = resultsMap[id]; + final resRaw = resultEntry != null ? resultEntry['content'] : null; + final filesRaw = resultEntry != null ? resultEntry['files'] : null; + + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write( + ' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"', + ) + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"'); + final resultStr = _jsonStringify(resRaw); + if (resultStr.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(resultStr)}"'); + } + final filesStr = _jsonStringify(filesRaw); + if (filesStr.isNotEmpty) { + attrs.write(' files="${_escapeHtmlAttr(filesStr)}"'); + } + buffer.writeln( + '
${resultEntry != null ? 'Tool Executed' : 'Executing...'}
', + ); + } + + return buffer.toString().trim(); +} + +String _synthesizeToolDetailsFromContentArray(List content) { + final buffer = StringBuffer(); + for (final item in content) { + if (item is! Map) continue; + final type = item['type']?.toString(); + if (type == null) continue; + if (type == 'tool_calls') { + final calls = >[]; + if (item['content'] is List) { + for (final entry in item['content'] as List) { + if (entry is Map) { + calls.add(Map.from(entry)); + } + } + } + + final results = >[]; + if (item['results'] is List) { + for (final entry in item['results'] as List) { + if (entry is Map) { + results.add(Map.from(entry)); + } + } + } + final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( + calls, + results, + ); + if (synthesized.isNotEmpty) { + buffer.writeln(synthesized); + } + continue; + } + + if (type == 'tool_call' || type == 'function_call') { + final name = (item['name'] ?? item['tool'] ?? 'tool').toString(); + final id = + (item['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final argsStr = _jsonStringify(item['arguments'] ?? item['args']); + final resStr = item['result'] ?? item['output'] ?? item['response']; + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write(' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"') + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); + final result = _jsonStringify(resStr); + if (result.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(result)}"'); + } + buffer.writeln( + '
${resStr != null ? 'Tool Executed' : 'Executing...'}
', + ); + } + } + return buffer.toString().trim(); +} + +String _jsonStringify(dynamic value) { + if (value == null) return ''; + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } +} + +String _escapeHtmlAttr(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +List> parseConversationSummariesWorker( + Map payload, +) { + final pinnedRaw = payload['pinned']; + final archivedRaw = payload['archived']; + final regularRaw = payload['regular']; + + final pinned = >[]; + if (pinnedRaw is List) { + for (final entry in pinnedRaw) { + if (entry is Map) { + pinned.add(Map.from(entry)); + } + } + } + + final archived = >[]; + if (archivedRaw is List) { + for (final entry in archivedRaw) { + if (entry is Map) { + archived.add(Map.from(entry)); + } + } + } + + final regular = >[]; + if (regularRaw is List) { + for (final entry in regularRaw) { + if (entry is Map) { + regular.add(Map.from(entry)); + } + } + } + + final summaries = >[]; + final pinnedIds = {}; + final archivedIds = {}; + + for (final entry in pinned) { + final summary = parseConversationSummary(entry); + summary['pinned'] = true; + summaries.add(summary); + pinnedIds.add(summary['id'] as String); + } + + for (final entry in archived) { + final summary = parseConversationSummary(entry); + summary['archived'] = true; + summaries.add(summary); + archivedIds.add(summary['id'] as String); + } + + for (final entry in regular) { + final summary = parseConversationSummary(entry); + final id = summary['id'] as String; + if (pinnedIds.contains(id) || archivedIds.contains(id)) { + continue; + } + summaries.add(summary); + } + + return summaries; +} + +Map parseFullConversationWorker(Map payload) { + final raw = payload['conversation']; + if (raw is Map) { + return parseFullConversation(raw); + } + if (raw is Map) { + return parseFullConversation(Map.from(raw)); + } + return parseFullConversation({}); +} diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index fa22197..9b44b5a 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import '../utils/debug_logger.dart'; import 'secure_credential_storage.dart'; +import 'worker_manager.dart'; /// Optimized storage service backed by Hive for non-sensitive data and /// FlutterSecureStorage for credentials. @@ -16,19 +17,22 @@ class OptimizedStorageService { OptimizedStorageService({ required FlutterSecureStorage secureStorage, required HiveBoxes boxes, + required WorkerManager workerManager, }) : _preferencesBox = boxes.preferences, _cachesBox = boxes.caches, _attachmentQueueBox = boxes.attachmentQueue, _metadataBox = boxes.metadata, _secureCredentialStorage = SecureCredentialStorage( instance: secureStorage, - ); + ), + _workerManager = workerManager; final Box _preferencesBox; final Box _cachesBox; final Box _attachmentQueueBox; final Box _metadataBox; final SecureCredentialStorage _secureCredentialStorage; + final WorkerManager _workerManager; static const String _authTokenKey = 'auth_token_v3'; static const String _activeServerIdKey = PreferenceKeys.activeServerId; @@ -298,19 +302,13 @@ class OptimizedStorageService { if (stored == null) { return const []; } - if (stored is String) { - final decoded = jsonDecode(stored) as List; - return decoded.map((item) => Conversation.fromJson(item)).toList(); - } - if (stored is List) { - return stored - .map( - (item) => - Conversation.fromJson(Map.from(item as Map)), - ) - .toList(); - } - return const []; + final parsed = await _workerManager + .schedule, List>>( + _decodeStoredConversationsWorker, + {'stored': stored}, + debugLabel: 'decode_local_conversations', + ); + return parsed.map(Conversation.fromJson).toList(growable: false); } catch (error, stack) { DebugLogger.error( 'Failed to retrieve local conversations', @@ -324,9 +322,13 @@ class OptimizedStorageService { Future saveLocalConversations(List conversations) async { try { - final serialized = conversations + final jsonReady = conversations .map((conversation) => conversation.toJson()) .toList(); + final serialized = await _workerManager + .schedule, String>(_encodeConversationsWorker, { + 'conversations': jsonReady, + }, debugLabel: 'encode_local_conversations'); await _cachesBox.put(_localConversationsKey, serialized); DebugLogger.log( 'Saved ${conversations.length} local conversations', @@ -455,3 +457,40 @@ class OptimizedStorageService { }; } } + +List> _decodeStoredConversationsWorker( + Map payload, +) { + final stored = payload['stored']; + if (stored is String) { + final decoded = jsonDecode(stored); + if (decoded is List) { + return decoded + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + return >[]; + } + + if (stored is List) { + return stored + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + + return >[]; +} + +String _encodeConversationsWorker(Map payload) { + final raw = payload['conversations']; + if (raw is List) { + return jsonEncode(raw); + } + if (raw is String) { + // Already encoded. + return raw; + } + return jsonEncode([]); +} diff --git a/lib/core/services/prompts_service.dart b/lib/core/services/prompts_service.dart index 2edad0d..2992ee3 100644 --- a/lib/core/services/prompts_service.dart +++ b/lib/core/services/prompts_service.dart @@ -13,12 +13,7 @@ class PromptsService { Future> getPrompts() async { try { - final List> response = await _apiService - .getPrompts(); - return response - .map((item) => Prompt.fromJson(item)) - .where((prompt) => prompt.command.isNotEmpty) - .toList(); + return await _apiService.getPrompts(); } on DioException catch (error) { throw ApiErrorHandler().transformError(error); } diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index b023918..0602839 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -17,6 +17,7 @@ import '../utils/debug_logger.dart'; import '../utils/openwebui_source_parser.dart'; import 'streaming_response_controller.dart'; import 'api_service.dart'; +import 'worker_manager.dart'; // Keep local verbosity toggle for socket logs const bool kSocketVerboseLogging = false; @@ -43,6 +44,74 @@ final _imageFilePattern = RegExp( caseSensitive: false, ); +List> _collectImageReferencesWorker(String content) { + final collected = >[]; + if (content.isEmpty) { + return collected; + } + + if (content.contains('')) { + final parsed = ToolCallsParser.parse(content); + if (parsed != null) { + for (final entry in parsed.toolCalls) { + if (entry.files != null && entry.files!.isNotEmpty) { + collected.addAll(_extractFilesFromResult(entry.files)); + } + if (entry.result != null) { + collected.addAll(_extractFilesFromResult(entry.result)); + } + } + } + } + + if (collected.isNotEmpty) { + return collected; + } + + final base64Matches = _base64ImagePattern.allMatches(content); + for (final match in base64Matches) { + final url = match.group(0); + if (url != null && url.isNotEmpty) { + collected.add({'type': 'image', 'url': url}); + } + } + + final urlMatches = _urlImagePattern.allMatches(content); + for (final match in urlMatches) { + final url = match.group(0); + if (url != null && url.isNotEmpty) { + collected.add({'type': 'image', 'url': url}); + } + } + + final jsonMatches = _jsonImagePattern.allMatches(content); + for (final match in jsonMatches) { + final url = _jsonUrlExtractPattern + .firstMatch(match.group(0) ?? '') + ?.group(1); + if (url != null && url.isNotEmpty) { + collected.add({'type': 'image', 'url': url}); + } + } + + final partialMatches = _partialResultsPattern.allMatches(content); + for (final match in partialMatches) { + final attrValue = match.group(2); + if (attrValue == null) continue; + try { + final decoded = json.decode(attrValue); + collected.addAll(_extractFilesFromResult(decoded)); + } catch (_) { + if (attrValue.startsWith('data:image/') || + _imageFilePattern.hasMatch(attrValue)) { + collected.add({'type': 'image', 'url': attrValue}); + } + } + } + + return collected; +} + class ActiveSocketStream { ActiveSocketStream({ required this.controller, @@ -70,6 +139,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ required String? activeConversationId, required ApiService api, required SocketService? socketService, + required WorkerManager workerManager, RegisterConversationDeltaListener? registerDeltaListener, // Message update callbacks required void Function(String) appendToLastMessage, @@ -228,88 +298,44 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ final content = msgs.last.content; if (content.isEmpty) return; - final collected = >[]; - - // Quick check: only parse tool calls if complete details blocks exist - if (content.contains('')) { - final parsed = ToolCallsParser.parse(content); - if (parsed != null) { - for (final entry in parsed.toolCalls) { - if (entry.files != null && entry.files!.isNotEmpty) { - collected.addAll(_extractFilesFromResult(entry.files)); - } - if (entry.result != null) { - collected.addAll(_extractFilesFromResult(entry.result)); - } - } - } - } - - if (collected.isEmpty) { - // Use pre-compiled patterns for better performance - final base64Matches = _base64ImagePattern.allMatches(content); - for (final match in base64Matches) { - final url = match.group(0); - if (url != null && url.isNotEmpty) { - collected.add({'type': 'image', 'url': url}); - } - } - - final urlMatches = _urlImagePattern.allMatches(content); - for (final match in urlMatches) { - final url = match.group(0); - if (url != null && url.isNotEmpty) { - collected.add({'type': 'image', 'url': url}); - } - } - - final jsonMatches = _jsonImagePattern.allMatches(content); - for (final match in jsonMatches) { - final url = _jsonUrlExtractPattern - .firstMatch(match.group(0) ?? '') - ?.group(1); - if (url != null && url.isNotEmpty) { - collected.add({'type': 'image', 'url': url}); - } - } - - final partialMatches = _partialResultsPattern.allMatches(content); - for (final match in partialMatches) { - final attrValue = match.group(2); - if (attrValue != null) { - try { - final decoded = json.decode(attrValue); - collected.addAll(_extractFilesFromResult(decoded)); - } catch (_) { - if (attrValue.startsWith('data:image/') || - _imageFilePattern.hasMatch(attrValue)) { - collected.add({'type': 'image', 'url': attrValue}); + final targetMessageId = msgs.last.id; + unawaited( + workerManager + .schedule>>( + _collectImageReferencesWorker, + content, + debugLabel: 'stream_collect_images', + ) + .then((collected) { + if (collected.isEmpty) return; + final currentMessages = getMessages(); + if (currentMessages.isEmpty) return; + final last = currentMessages.last; + if (last.id != targetMessageId || last.role != 'assistant') { + return; } - } - } - } - } - if (collected.isEmpty) return; + final existing = last.files ?? >[]; + final seen = { + for (final f in existing) + if (f['url'] is String) (f['url'] as String) else '', + }..removeWhere((e) => e.isEmpty); - final existing = msgs.last.files ?? >[]; - final seen = { - for (final f in existing) - if (f['url'] is String) (f['url'] as String) else '', - }..removeWhere((e) => e.isEmpty); + final merged = >[...existing]; + for (final f in collected) { + final url = f['url'] as String?; + if (url != null && url.isNotEmpty && !seen.contains(url)) { + merged.add({'type': 'image', 'url': url}); + seen.add(url); + } + } - final merged = >[...existing]; - for (final f in collected) { - final url = f['url'] as String?; - if (url != null && url.isNotEmpty && !seen.contains(url)) { - merged.add({'type': 'image', 'url': url}); - seen.add(url); - } - } - - if (merged.length != existing.length) { - updateLastMessageWith((m) => m.copyWith(files: merged)); - } + if (merged.length != existing.length) { + updateLastMessageWith((m) => m.copyWith(files: merged)); + } + }) + .catchError((_) {}), + ); } catch (_) {} } diff --git a/lib/core/services/worker_manager.dart b/lib/core/services/worker_manager.dart new file mode 100644 index 0000000..3d095fa --- /dev/null +++ b/lib/core/services/worker_manager.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../utils/debug_logger.dart'; + +part 'worker_manager.g.dart'; + +/// Signature of a task that can be executed by [WorkerManager]. +typedef WorkerTask = ComputeCallback; + +/// Coordinates CPU intensive work off the UI isolate with lightweight pooling. +/// +/// The manager throttles concurrent isolate usage to avoid overwhelming the +/// platform while still enabling parallel work. On web the callback executes +/// synchronously because secondary isolates are not supported. +class WorkerManager { + WorkerManager({int maxConcurrentTasks = _defaultMaxConcurrentTasks}) + : _maxConcurrentTasks = math.max(1, maxConcurrentTasks) { + DebugLogger.log( + 'initialized', + scope: 'worker', + data: {'max': _maxConcurrentTasks}, + ); + } + + static const int _defaultMaxConcurrentTasks = 2; + + final int _maxConcurrentTasks; + final Queue<_EnqueuedJob> _pendingJobs = Queue<_EnqueuedJob>(); + bool _disposed = false; + int _activeJobs = 0; + int _jobCounter = 0; + + /// Schedule [callback] with [message] to run on a worker isolate. + /// + /// The [callback] must be a top-level or static function, mirroring the + /// constraints of `compute`. Errors from the task are propagated to the + /// returned [Future]. + Future schedule( + WorkerTask callback, + Q message, { + String? debugLabel, + }) { + if (_disposed) { + return Future.error(StateError('WorkerManager has been disposed')); + } + + final jobId = ++_jobCounter; + final completer = Completer(); + final job = _EnqueuedJob( + id: jobId, + debugLabel: debugLabel, + run: () { + if (kIsWeb) { + return Future.sync(() => callback(message)); + } + return compute(callback, message); + }, + onComplete: (value) { + if (!completer.isCompleted) { + completer.complete(value as R); + } + }, + onError: (error, stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + ); + + _pendingJobs.add(job); + + DebugLogger.log( + 'queued', + scope: 'worker', + data: { + 'id': jobId, + if (debugLabel != null) 'label': debugLabel, + 'pending': _pendingJobs.length, + 'active': _activeJobs, + }, + ); + + _processQueue(); + + return completer.future; + } + + /// Dispose the manager and reject all pending work. + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + + while (_pendingJobs.isNotEmpty) { + final job = _pendingJobs.removeFirst(); + job.cancel( + StateError('WorkerManager disposed before job ${job.id} started'), + ); + } + + DebugLogger.log('disposed', scope: 'worker', data: {'active': _activeJobs}); + } + + void _processQueue() { + if (_disposed) { + return; + } + + while (_activeJobs < _maxConcurrentTasks && _pendingJobs.isNotEmpty) { + final job = _pendingJobs.removeFirst(); + _startJob(job); + } + } + + void _startJob(_EnqueuedJob job) { + _activeJobs++; + + DebugLogger.log( + 'started', + scope: 'worker', + data: { + 'id': job.id, + if (job.debugLabel != null) 'label': job.debugLabel, + 'active': _activeJobs, + }, + ); + + unawaited(_runJob(job)); + } + + Future _runJob(_EnqueuedJob job) async { + try { + final result = await job.run(); + job.onComplete(result); + + DebugLogger.log( + 'completed', + scope: 'worker', + data: { + 'id': job.id, + if (job.debugLabel != null) 'label': job.debugLabel, + 'pending': _pendingJobs.length, + }, + ); + } catch (error, stackTrace) { + job.onError(error, stackTrace); + + DebugLogger.error( + 'failed', + scope: 'worker', + error: error, + stackTrace: stackTrace, + data: { + 'id': job.id, + if (job.debugLabel != null) 'label': job.debugLabel, + }, + ); + } finally { + _activeJobs = math.max(0, _activeJobs - 1); + _processQueue(); + } + } +} + +/// Keep a single [WorkerManager] alive across the app. +@Riverpod(keepAlive: true) +class WorkerManagerNotifier extends _$WorkerManagerNotifier { + @override + WorkerManager build() { + final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks; + final manager = WorkerManager(maxConcurrentTasks: concurrency); + ref.onDispose(manager.dispose); + return manager; + } +} + +class _EnqueuedJob { + _EnqueuedJob({ + required this.id, + required this.run, + required this.onComplete, + required this.onError, + this.debugLabel, + }); + + final int id; + final FutureOr Function() run; + final void Function(dynamic value) onComplete; + final void Function(Object error, StackTrace stackTrace) onError; + final String? debugLabel; + final DateTime queuedAt = DateTime.now(); + + void cancel(Object error) { + onError(error, StackTrace.current); + } +} diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 5d13db5..4dabc16 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -11,6 +11,7 @@ import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/api_service.dart'; +import '../../../core/services/worker_manager.dart'; import '../../../core/services/input_validation_service.dart'; import '../../../core/services/navigation_service.dart'; import '../../../core/widgets/error_boundary.dart'; @@ -81,7 +82,11 @@ class _ServerConnectionPageState extends ConsumerState { allowSelfSignedCertificates: _allowSelfSignedCertificates, ); - final api = ApiService(serverConfig: tempConfig); + final workerManager = ref.read(workerManagerProvider); + final api = ApiService( + serverConfig: tempConfig, + workerManager: workerManager, + ); final isHealthy = await api.checkHealth(); if (!isHealthy) { throw Exception('This does not appear to be an Open-WebUI server.'); diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 35e4bbd..cea62f7 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -14,6 +14,7 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/services/conversation_delta_listener.dart'; import '../../../core/services/streaming_helper.dart'; import '../../../core/services/streaming_response_controller.dart'; +import '../../../core/services/worker_manager.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/markdown_stream_formatter.dart'; import '../../../core/utils/tool_calls_parser.dart'; @@ -718,9 +719,40 @@ class ChatMessagesNotifier extends Notifier> { _messageStream = null; _stopRemoteTaskMonitor(); + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation != null) { + final updatedActive = activeConversation.copyWith( + messages: List.unmodifiable(state), + updatedAt: DateTime.now(), + ); + ref.read(activeConversationProvider.notifier).set(updatedActive); + + final conversationsAsync = ref.read(conversationsProvider); + Conversation? summary; + conversationsAsync.maybeWhen( + data: (conversations) { + for (final conversation in conversations) { + if (conversation.id == updatedActive.id) { + summary = conversation; + break; + } + } + }, + orElse: () {}, + ); + final updatedSummary = + (summary ?? updatedActive.copyWith(messages: const [])).copyWith( + updatedAt: updatedActive.updatedAt, + ); + + ref + .read(conversationsProvider.notifier) + .upsertConversation(updatedSummary.copyWith(messages: const [])); + } + // Trigger a refresh of the conversations list so UI like the Chats Drawer - // can pick up updated titles and ordering once streaming completes. - // Best-effort: ignore if ref lifecycle/context prevents invalidation. + // can reconcile with the server once streaming completes. Best-effort: + // ignore if ref lifecycle/context prevents invalidation. try { refreshConversationsCache(ref); } catch (_) {} @@ -1449,6 +1481,7 @@ Future regenerateMessage( activeConversationId: activeConversation.id, api: api!, socketService: socketService, + workerManager: ref.read(workerManagerProvider), registerDeltaListener: registerDeltaListener, appendToLastMessage: (c) => ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), @@ -1478,6 +1511,15 @@ Future regenerateMessage( ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); + ref + .read(conversationsProvider.notifier) + .updateConversation( + active.id, + (conversation) => conversation.copyWith( + title: newTitle, + updatedAt: DateTime.now(), + ), + ); } refreshConversationsCache(ref); }, @@ -1490,6 +1532,9 @@ Future regenerateMessage( try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); + ref + .read(conversationsProvider.notifier) + .upsertConversation(refreshed.copyWith(messages: const [])); } catch (_) {} }); } @@ -1623,6 +1668,12 @@ Future _sendMessageInternal( ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + ref + .read(conversationsProvider.notifier) + .upsertConversation( + updatedConversation.copyWith(updatedAt: DateTime.now()), + ); + // Invalidate conversations provider to refresh the list // Adding a small delay to prevent rapid invalidations that could cause duplicates Future.delayed(const Duration(milliseconds: 100), () { @@ -1997,6 +2048,7 @@ Future _sendMessageInternal( activeConversationId: activeConversation?.id, api: api!, socketService: socketService, + workerManager: ref.read(workerManagerProvider), registerDeltaListener: registerDeltaListener, appendToLastMessage: (c) => ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), @@ -2026,6 +2078,15 @@ Future _sendMessageInternal( ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); + ref + .read(conversationsProvider.notifier) + .updateConversation( + active.id, + (conversation) => conversation.copyWith( + title: newTitle, + updatedAt: DateTime.now(), + ), + ); } refreshConversationsCache(ref); }, @@ -2038,6 +2099,9 @@ Future _sendMessageInternal( try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); + ref + .read(conversationsProvider.notifier) + .upsertConversation(refreshed.copyWith(messages: const [])); } catch (_) {} }); } @@ -2201,6 +2265,14 @@ Future pinConversation( await api.pinConversation(conversationId, pinned); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => + conversation.copyWith(pinned: pinned, updatedAt: DateTime.now()), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); @@ -2240,6 +2312,16 @@ Future archiveConversation( await api.archiveConversation(conversationId, archived); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => conversation.copyWith( + archived: archived, + updatedAt: DateTime.now(), + ), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); } catch (e) { @@ -2266,6 +2348,16 @@ Future shareConversation(WidgetRef ref, String conversationId) async { final shareId = await api.shareConversation(conversationId); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => conversation.copyWith( + shareId: shareId, + updatedAt: DateTime.now(), + ), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); @@ -2290,6 +2382,11 @@ Future cloneConversation(WidgetRef ref, String conversationId) async { // The ChatMessagesNotifier will automatically load messages when activeConversation changes // Refresh conversations list to show the new conversation + ref + .read(conversationsProvider.notifier) + .upsertConversation( + clonedConversation.copyWith(updatedAt: DateTime.now()), + ); refreshConversationsCache(ref); } catch (e) { DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers'); diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 0359fba..00a12ce 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -24,6 +24,7 @@ import '../providers/chat_providers.dart' show sendMessageWithContainer; import '../../../core/utils/debug_logger.dart'; import 'sources/openwebui_sources.dart'; import '../providers/assistant_response_builder_provider.dart'; +import '../../../core/services/worker_manager.dart'; // Pre-compiled regex patterns for image processing (performance optimization) final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); @@ -104,7 +105,7 @@ class _AssistantMessageWidgetState extends ConsumerState ); // Parse reasoning and tool-calls sections - _reparseSections(); + unawaited(_reparseSections()); _updateTypingIndicatorGate(); } @@ -121,7 +122,7 @@ class _AssistantMessageWidgetState extends ConsumerState // Re-parse sections when message content changes if (oldWidget.message.content != widget.message.content) { - _reparseSections(); + unawaited(_reparseSections()); _updateTypingIndicatorGate(); } @@ -141,7 +142,7 @@ class _AssistantMessageWidgetState extends ConsumerState } } - void _reparseSections() { + Future _reparseSections() async { final raw0 = _activeVersionIndex >= 0 ? (widget.message.versions[_activeVersionIndex].content as String?) ?? '' @@ -162,11 +163,13 @@ class _AssistantMessageWidgetState extends ConsumerState final out = []; final textBuf = StringBuffer(); + final textSegments = []; if (rSegs == null || rSegs.isEmpty) { final tSegs = ToolCallsParser.segments(raw); if (tSegs == null || tSegs.isEmpty) { out.add(MessageSegment.text(raw)); textBuf.write(raw); + textSegments.add(raw); } else { for (final s in tSegs) { if (s.isToolCall && s.entry != null) { @@ -174,6 +177,7 @@ class _AssistantMessageWidgetState extends ConsumerState } else if ((s.text ?? '').isNotEmpty) { out.add(MessageSegment.text(s.text!)); textBuf.write(s.text); + textSegments.add(s.text!); } } } @@ -187,6 +191,7 @@ class _AssistantMessageWidgetState extends ConsumerState if (tSegs == null || tSegs.isEmpty) { out.add(MessageSegment.text(t)); textBuf.write(t); + textSegments.add(t); } else { for (final s in tSegs) { if (s.isToolCall && s.entry != null) { @@ -194,6 +199,7 @@ class _AssistantMessageWidgetState extends ConsumerState } else if ((s.text ?? '').isNotEmpty) { out.add(MessageSegment.text(s.text!)); textBuf.write(s.text); + textSegments.add(s.text!); } } } @@ -202,8 +208,19 @@ class _AssistantMessageWidgetState extends ConsumerState } final segments = out.isEmpty ? [MessageSegment.text(raw)] : out; - final speechText = _buildTtsPlainText(segments, raw); + String speechText; + try { + final worker = ref.read(workerManagerProvider); + speechText = await worker.schedule, String>( + _buildTtsPlainTextWorker, + {'segments': textSegments, 'fallback': raw}, + debugLabel: 'tts_plain_text', + ); + } catch (_) { + speechText = _buildTtsPlainTextFallback(textSegments, raw); + } + if (!mounted) return; setState(() { _segments = segments; _ttsPlainText = speechText; @@ -248,18 +265,14 @@ class _AssistantMessageWidgetState extends ConsumerState } } - String _buildTtsPlainText(List segments, String fallback) { + String _buildTtsPlainTextFallback(List segments, String fallback) { if (segments.isEmpty) { return MarkdownToText.convert(fallback); } final buffer = StringBuffer(); for (final segment in segments) { - if (!segment.isText) { - continue; - } - final text = segment.text ?? ''; - final sanitized = MarkdownToText.convert(text); + final sanitized = MarkdownToText.convert(segment); if (sanitized.isEmpty) { continue; } @@ -1157,7 +1170,7 @@ class _AssistantMessageWidgetState extends ConsumerState } else if (_activeVersionIndex > 0) { _activeVersionIndex -= 1; } - _reparseSections(); + unawaited(_reparseSections()); }); }, ), @@ -1177,7 +1190,7 @@ class _AssistantMessageWidgetState extends ConsumerState } else { _activeVersionIndex = -1; // move to live } - _reparseSections(); + unawaited(_reparseSections()); }); }, ), @@ -1329,6 +1342,34 @@ class _AssistantMessageWidgetState extends ConsumerState } } +String _buildTtsPlainTextWorker(Map payload) { + final rawSegments = payload['segments']; + final fallback = payload['fallback'] as String? ?? ''; + final segments = rawSegments is List ? rawSegments.cast() : const []; + + if (segments.isEmpty) { + return MarkdownToText.convert(fallback); + } + + final buffer = StringBuffer(); + for (final segment in segments) { + if (segment is! String || segment.isEmpty) continue; + final sanitized = MarkdownToText.convert(segment); + if (sanitized.isEmpty) continue; + if (buffer.isNotEmpty) { + buffer.writeln(); + buffer.writeln(); + } + buffer.write(sanitized); + } + + final result = buffer.toString().trim(); + if (result.isEmpty) { + return MarkdownToText.convert(fallback); + } + return result; +} + class StatusHistoryTimeline extends StatefulWidget { const StatusHistoryTimeline({ super.key, diff --git a/lib/features/chat/widgets/enhanced_attachment.dart b/lib/features/chat/widgets/enhanced_attachment.dart index 86b7dff..fd9f199 100644 --- a/lib/features/chat/widgets/enhanced_attachment.dart +++ b/lib/features/chat/widgets/enhanced_attachment.dart @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:io'; import 'dart:convert'; +import '../../../core/services/worker_manager.dart'; class EnhancedAttachment extends ConsumerStatefulWidget { final String attachmentId; @@ -102,12 +103,14 @@ class _EnhancedAttachmentState extends ConsumerState { final dir = await getTemporaryDirectory(); final filePath = '${dir.path}/$filename'; + final worker = ref.read(workerManagerProvider); try { - if (content.length > 128 && - RegExp( - r'^[A-Za-z0-9+/=\r\n]+$', - ).hasMatch(content.replaceAll('\n', ''))) { - final bytes = base64Decode(content.replaceAll('\n', '')); + if (_looksLikeBase64(content)) { + final bytes = await worker.schedule( + _decodeAttachmentBase64, + content, + debugLabel: 'attachment_decode_bytes', + ); await File(filePath).writeAsBytes(bytes, flush: true); } else { await File(filePath).writeAsString(content, flush: true); @@ -291,3 +294,14 @@ class _EnhancedAttachmentState extends ConsumerState { return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } } + +bool _looksLikeBase64(String content) { + if (content.length <= 128) return false; + final sanitized = content.replaceAll('\n', ''); + return RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(sanitized); +} + +Uint8List _decodeAttachmentBase64(String raw) { + final sanitized = raw.replaceAll('\n', ''); + return base64Decode(sanitized); +} diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart index c60cb72..a570aca 100644 --- a/lib/features/chat/widgets/enhanced_image_attachment.dart +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -15,6 +15,7 @@ import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/network/self_signed_image_cache_manager.dart'; import '../../../core/network/image_header_utils.dart'; +import '../../../core/services/worker_manager.dart'; // Simple global cache to prevent reloading final _globalImageCache = {}; @@ -23,13 +24,6 @@ final _globalErrorStates = {}; final _globalImageBytesCache = {}; final _base64WhitespacePattern = RegExp(r'\s'); -Future _decodeImageDataAsync(String data) async { - if (kIsWeb) { - return _decodeImageData(data); - } - return compute(_decodeImageData, data); -} - Uint8List _decodeImageData(String data) { var payload = data; if (payload.startsWith('data:')) { @@ -233,7 +227,12 @@ class _EnhancedImageAttachmentState if (_isDecoding) return; _isDecoding = true; try { - final bytes = await _decodeImageDataAsync(data); + final worker = ref.read(workerManagerProvider); + final bytes = await worker.schedule( + _decodeImageData, + data, + debugLabel: 'decode_image', + ); _globalImageBytesCache[widget.attachmentId] = bytes; if (!mounted) return; setState(() { @@ -418,7 +417,7 @@ class _EnhancedImageAttachmentState // Get authentication headers if available final headers = buildImageHeadersFromWidgetRef(ref); - final cacheManager = ref.read(selfSignedImageCacheManagerProvider); + final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); final imageWidget = CachedNetworkImage( key: ValueKey('image_${widget.attachmentId}'), imageUrl: _cachedImageData!, @@ -549,7 +548,7 @@ class FullScreenImageViewer extends ConsumerWidget { // Get authentication headers if available final headers = buildImageHeadersFromWidgetRef(ref); - final cacheManager = ref.read(selfSignedImageCacheManagerProvider); + final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); imageWidget = CachedNetworkImage( imageUrl: imageData, fit: BoxFit.contain, diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 33d36c9..b977de7 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -770,8 +770,10 @@ class _ChatsDrawerState extends ConsumerState { try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service'); - await api.createFolder(name: name); + final created = await api.createFolder(name: name); + final folder = Folder.fromJson(Map.from(created)); HapticFeedback.lightImpact(); + ref.read(foldersProvider.notifier).upsertFolder(folder); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return; @@ -813,6 +815,15 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.moveConversationToFolder(details.data.id, folderId); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + details.data.id, + (conversation) => conversation.copyWith( + folderId: folderId, + updatedAt: DateTime.now(), + ), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { DebugLogger.error( @@ -1085,6 +1096,13 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.updateFolder(folderId, name: newName); HapticFeedback.selectionClick(); + ref + .read(foldersProvider.notifier) + .updateFolder( + folderId, + (folder) => + folder.copyWith(name: newName, updatedAt: DateTime.now()), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return; @@ -1120,6 +1138,7 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.deleteFolder(folderId); HapticFeedback.mediumImpact(); + ref.read(foldersProvider.notifier).removeFolder(folderId); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return; @@ -1153,6 +1172,15 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.moveConversationToFolder(details.data.id, null); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + details.data.id, + (conversation) => conversation.copyWith( + folderId: null, + updatedAt: DateTime.now(), + ), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { DebugLogger.error( diff --git a/lib/shared/utils/conversation_context_menu.dart b/lib/shared/utils/conversation_context_menu.dart index 893a1e1..45b1a24 100644 --- a/lib/shared/utils/conversation_context_menu.dart +++ b/lib/shared/utils/conversation_context_menu.dart @@ -221,6 +221,13 @@ Future _renameConversation( if (api == null) throw Exception('No API service'); await api.updateConversation(conversationId, title: newName); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => + conversation.copyWith(title: newName, updatedAt: DateTime.now()), + ); refreshConversationsCache(ref); final active = ref.read(activeConversationProvider); if (active?.id == conversationId) { @@ -257,6 +264,7 @@ Future _confirmAndDeleteConversation( if (api == null) throw Exception('No API service'); await api.deleteConversation(conversationId); HapticFeedback.mediumImpact(); + ref.read(conversationsProvider.notifier).removeConversation(conversationId); final active = ref.read(activeConversationProvider); if (active?.id == conversationId) { ref.read(activeConversationProvider.notifier).clear(); diff --git a/lib/shared/widgets/user_avatar.dart b/lib/shared/widgets/user_avatar.dart index 0bd8b7c..f5d5909 100644 --- a/lib/shared/widgets/user_avatar.dart +++ b/lib/shared/widgets/user_avatar.dart @@ -2,13 +2,13 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:conduit/core/network/image_header_utils.dart'; +import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/brand_service.dart'; import '../theme/theme_extensions.dart'; -import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; -import 'package:conduit/core/network/image_header_utils.dart'; typedef AvatarWidgetBuilder = Widget Function(BuildContext context, double size); @@ -59,7 +59,7 @@ class AvatarImage extends ConsumerWidget { // Build auth/custom headers when loading from network final headers = buildImageHeadersFromWidgetRef(ref); - final cacheManager = ref.read(selfSignedImageCacheManagerProvider); + final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); return ClipRRect( borderRadius: _radius,