feat(api): Optimize chat fetching with parallel pagination

This commit is contained in:
cogwheel0
2025-11-28 13:49:21 +05:30
parent e61f53b69f
commit f10ef6d7c1
2 changed files with 148 additions and 79 deletions

View File

@@ -495,49 +495,13 @@ class ApiService {
List<dynamic> allRegularChats = [];
if (limit == null) {
// Fetch all conversations using pagination
// OpenWebUI expects 1-based pagination for the `page` query param.
// Using 0 triggers server-side offset calculation like `offset = page*limit - limit`,
// which becomes negative for page=0 and causes a DB error.
int currentPage = 1;
while (true) {
final response = await _dio.get(
'/api/v1/chats/',
queryParameters: {
'page': currentPage,
'include_folders': true,
'include_pinned': true,
},
);
if (response.data is! List) {
throw Exception(
'Expected array of chats, got ${response.data.runtimeType}',
);
}
final pageChats = response.data as List;
if (pageChats.isEmpty) {
break;
}
allRegularChats.addAll(pageChats);
currentPage++;
// Safety break to avoid infinite loops (adjust as needed)
if (currentPage > 100) {
_traceApi(
'WARNING: Reached maximum page limit (100), stopping pagination',
);
break;
}
}
_traceApi(
'Fetched total of ${allRegularChats.length} conversations across $currentPage pages',
// Fetch all conversations using parallel pagination for better performance
// Main chats endpoint uses 50 items per page
allRegularChats = await _fetchAllPagedResults(
endpoint: '/api/v1/chats/',
baseParams: {'include_folders': true, 'include_pinned': true},
expectedPageSize: 50,
debugLabel: 'conversations',
);
} else {
// Original single page fetch
@@ -644,6 +608,111 @@ class ApiService {
return <dynamic>[];
}
/// Fetches all pages from a paginated endpoint using parallel batch requests.
///
/// This method fetches pages in parallel batches for better performance,
/// rather than fetching sequentially one page at a time.
///
/// [endpoint] - The API endpoint to fetch from
/// [baseParams] - Base query parameters to include with each request
/// [expectedPageSize] - Expected items per page from the API (for early exit
/// optimization). If the first page has fewer items, no more requests are
/// made. Use 50 for main chats, 10 for folder chats.
/// [batchSize] - Number of pages to fetch in parallel (default: 5)
/// [maxPages] - Maximum number of pages to fetch (default: 100)
/// [debugLabel] - Label for debug logging
Future<List<Map<String, dynamic>>> _fetchAllPagedResults({
required String endpoint,
Map<String, dynamic>? baseParams,
required int expectedPageSize,
int batchSize = 5,
int maxPages = 100,
String? debugLabel,
}) async {
final results = <Map<String, dynamic>>[];
final label = debugLabel ?? endpoint;
// Fetch first page to check if there's data
final firstResponse = await _dio.get(
endpoint,
queryParameters: {...?baseParams, 'page': 1},
);
final firstData = firstResponse.data;
if (firstData is! List) {
throw Exception('Expected array of $label, got ${firstData.runtimeType}');
}
if (firstData.isEmpty) {
_traceApi('$label: no results on first page');
return results;
}
results.addAll(firstData.whereType<Map<String, dynamic>>());
// Use unfiltered length for pagination detection since the API returns
// the same count regardless of filtering. If the first page has fewer
// items than expected, we know there are no more pages.
final firstPageCount = firstData.length;
if (firstPageCount < expectedPageSize) {
_traceApi('$label: fetched ${results.length} items (single page)');
return results;
}
// Fetch remaining pages in parallel batches
int currentPage = 2;
int totalPages = 1;
while (currentPage <= maxPages) {
final futures = <Future<Response<dynamic>>>[];
// Queue up a batch of parallel requests
for (int i = 0; i < batchSize && currentPage <= maxPages; i++) {
futures.add(
_dio.get(
endpoint,
queryParameters: {...?baseParams, 'page': currentPage++},
),
);
}
// Execute batch in parallel
final responses = await Future.wait(futures);
bool hasMore = false;
for (final response in responses) {
final data = response.data;
// Validate response type - throw on non-list (e.g., error objects)
// to preserve original error-surfacing behavior
if (data is! List) {
throw Exception('Expected array of $label, got ${data.runtimeType}');
}
if (data.isNotEmpty) {
results.addAll(data.whereType<Map<String, dynamic>>());
totalPages++;
// If this page is full (has expected number of items), there might
// be more pages. Use unfiltered length for consistent detection.
if (data.length >= expectedPageSize) {
hasMore = true;
}
}
}
// Stop if no page in this batch was full
if (!hasMore) break;
}
if (currentPage > maxPages) {
_traceApi('WARNING: $label reached max page limit ($maxPages)');
}
_traceApi(
'$label: fetched ${results.length} items across $totalPages pages',
);
return results;
}
// Parse OpenWebUI chat format to our Conversation format
Future<Conversation> getConversation(String id) async {
DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id});
@@ -1130,45 +1199,23 @@ class ApiService {
Future<List<Conversation>> getFolderConversationSummaries(
String folderId,
) async {
_traceApi('Fetching conversation summaries in folder: $folderId');
// The backend endpoint has a hardcoded limit of 10 items per page,
// so we need to paginate through all pages to get all conversations.
final List<Map<String, dynamic>> allChats = [];
int currentPage = 1;
while (true) {
final response = await _dio.get(
'/api/v1/chats/folder/$folderId/list',
queryParameters: {'page': currentPage},
);
final data = response.data;
if (data is! List || data.isEmpty) {
break;
}
allChats.addAll(data.whereType<Map<String, dynamic>>());
currentPage++;
// Safety limit to prevent infinite loops (matches getConversations)
if (currentPage > 100) {
_traceApi(
'WARNING: Reached maximum page limit (100) for folder $folderId',
);
break;
}
}
_traceApi(
'Fetched ${allChats.length} conversations in folder $folderId '
'across ${currentPage - 1} pages',
// so we use parallel pagination to fetch all conversations efficiently.
final allChats = await _fetchAllPagedResults(
endpoint: '/api/v1/chats/folder/$folderId/list',
expectedPageSize: 10,
debugLabel: 'folder-$folderId',
);
return allChats
.map(parseConversationSummary)
.map(Conversation.fromJson)
.toList(growable: false);
// Parse in background isolate for better UI responsiveness
final parsedJson = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
parseFolderSummariesWorker,
{'chats': allChats},
debugLabel: 'parse_folder_$folderId',
);
return parsedJson.map(Conversation.fromJson).toList(growable: false);
}
// Tags

View File

@@ -723,3 +723,25 @@ Map<String, dynamic> parseFullConversationWorker(Map<String, dynamic> payload) {
}
return parseFullConversation(<String, dynamic>{});
}
/// Worker function for parsing folder conversation summaries in a background
/// isolate. Takes a list of raw chat data and returns parsed summaries.
List<Map<String, dynamic>> parseFolderSummariesWorker(
Map<String, dynamic> payload,
) {
final chatsRaw = payload['chats'];
if (chatsRaw is! List) {
return const [];
}
final summaries = <Map<String, dynamic>>[];
for (final entry in chatsRaw) {
if (entry is Map) {
final map = entry is Map<String, dynamic>
? entry
: Map<String, dynamic>.from(entry);
summaries.add(parseConversationSummary(map));
}
}
return summaries;
}