Merge pull request #194 from cogwheel0/implement-pagination-conversation-summaries
implement-pagination-conversation-summaries
This commit is contained in:
@@ -495,49 +495,13 @@ class ApiService {
|
|||||||
List<dynamic> allRegularChats = [];
|
List<dynamic> allRegularChats = [];
|
||||||
|
|
||||||
if (limit == null) {
|
if (limit == null) {
|
||||||
// Fetch all conversations using pagination
|
// Fetch all conversations using parallel pagination for better performance
|
||||||
|
// Main chats endpoint uses 50 items per page
|
||||||
// OpenWebUI expects 1-based pagination for the `page` query param.
|
allRegularChats = await _fetchAllPagedResults(
|
||||||
// Using 0 triggers server-side offset calculation like `offset = page*limit - limit`,
|
endpoint: '/api/v1/chats/',
|
||||||
// which becomes negative for page=0 and causes a DB error.
|
baseParams: {'include_folders': true, 'include_pinned': true},
|
||||||
int currentPage = 1;
|
expectedPageSize: 50,
|
||||||
|
debugLabel: 'conversations',
|
||||||
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',
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Original single page fetch
|
// Original single page fetch
|
||||||
@@ -644,6 +608,111 @@ class ApiService {
|
|||||||
return <dynamic>[];
|
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
|
// Parse OpenWebUI chat format to our Conversation format
|
||||||
Future<Conversation> getConversation(String id) async {
|
Future<Conversation> getConversation(String id) async {
|
||||||
DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id});
|
DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id});
|
||||||
@@ -1130,18 +1199,23 @@ class ApiService {
|
|||||||
Future<List<Conversation>> getFolderConversationSummaries(
|
Future<List<Conversation>> getFolderConversationSummaries(
|
||||||
String folderId,
|
String folderId,
|
||||||
) async {
|
) async {
|
||||||
_traceApi('Fetching conversation summaries in folder: $folderId');
|
// The backend endpoint has a hardcoded limit of 10 items per page,
|
||||||
final response = await _dio.get('/api/v1/chats/folder/$folderId/list');
|
// so we use parallel pagination to fetch all conversations efficiently.
|
||||||
final data = response.data;
|
final allChats = await _fetchAllPagedResults(
|
||||||
if (data is! List) {
|
endpoint: '/api/v1/chats/folder/$folderId/list',
|
||||||
return const [];
|
expectedPageSize: 10,
|
||||||
}
|
debugLabel: 'folder-$folderId',
|
||||||
final normalized = data
|
);
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(parseConversationSummary)
|
// Parse in background isolate for better UI responsiveness
|
||||||
.map(Conversation.fromJson)
|
final parsedJson = await _workerManager
|
||||||
.toList(growable: false);
|
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
||||||
return normalized;
|
parseFolderSummariesWorker,
|
||||||
|
{'chats': allChats},
|
||||||
|
debugLabel: 'parse_folder_$folderId',
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedJson.map(Conversation.fromJson).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
@@ -2838,7 +2912,9 @@ class ApiService {
|
|||||||
} else if (respData['error'] != null) {
|
} else if (respData['error'] != null) {
|
||||||
_traceApi('Server error: ${respData['error']}');
|
_traceApi('Server error: ${respData['error']}');
|
||||||
if (!streamController.isClosed) {
|
if (!streamController.isClosed) {
|
||||||
streamController.addError(Exception(respData['error'].toString()));
|
streamController.addError(
|
||||||
|
Exception(respData['error'].toString()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,3 +723,25 @@ Map<String, dynamic> parseFullConversationWorker(Map<String, dynamic> payload) {
|
|||||||
}
|
}
|
||||||
return parseFullConversation(<String, dynamic>{});
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user