feat(api): Optimize conversation parsing with worker-based decoding

This commit is contained in:
cogwheel0
2025-11-01 14:54:08 +05:30
parent a374c744ef
commit a005c14a67
7 changed files with 497 additions and 301 deletions

View File

@@ -58,6 +58,7 @@ final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
return OptimizedStorageService( return OptimizedStorageService(
secureStorage: ref.watch(secureStorageProvider), secureStorage: ref.watch(secureStorageProvider),
boxes: ref.watch(hiveBoxesProvider), boxes: ref.watch(hiveBoxesProvider),
workerManager: ref.watch(workerManagerProvider),
); );
}); });
@@ -882,311 +883,341 @@ class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp {
void set(DateTime? timestamp) => state = timestamp; void set(DateTime? timestamp) => state = timestamp;
} }
/// Clears the in-memory timestamp cache and invalidates the conversations /// Clears the in-memory timestamp cache and triggers a refresh of the
/// provider so the next read forces a refetch. Optionally invalidates the /// conversations provider. Optionally refreshes the folders provider so folder
/// folders provider when folder metadata must stay in sync with conversations. /// metadata stays in sync.
void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) { void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
ref.read(_conversationsCacheTimestampProvider.notifier).set(null); ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
ref.invalidate(conversationsProvider); final notifier = ref.read(conversationsProvider.notifier);
if (includeFolders) { unawaited(notifier.refresh(includeFolders: includeFolders));
ref.invalidate(foldersProvider);
}
} }
// Conversation providers - Now using correct OpenWebUI API with caching // Conversation providers - Now using correct OpenWebUI API with caching and
// keepAlive to maintain cache during authenticated session // immediate mutation helpers.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
Future<List<Conversation>> conversations(Ref ref) async { class Conversations extends _$Conversations {
// Do not fetch protected data until authenticated. Use watch so we refetch @override
// when the auth state transitions in either direction. Future<List<Conversation>> build() async {
final authed = ref.watch(isAuthenticatedProvider2); final authed = ref.watch(isAuthenticatedProvider2);
if (!authed) { if (!authed) {
DebugLogger.log('skip-unauthed', scope: 'conversations'); DebugLogger.log('skip-unauthed', scope: 'conversations');
return []; _updateCacheTimestamp(null);
} return const [];
// Check if we have a recent cache (within 5 seconds) }
final lastFetch = ref.read(_conversationsCacheTimestampProvider);
if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) { if (ref.watch(reviewerModeProvider)) {
DebugLogger.log( return _demoConversations();
'cache-hit', }
scope: 'conversations',
data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds}, return _loadRemoteConversations();
);
// 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 [];
} }
try { Future<void> refresh({bool includeFolders = false}) async {
DebugLogger.log('fetch-start', scope: 'conversations'); final authed = ref.read(isAuthenticatedProvider2);
final conversations = await api if (!authed) {
.getConversations(); // Fetch all conversations _updateCacheTimestamp(null);
DebugLogger.log( state = AsyncData<List<Conversation>>(<Conversation>[]);
'fetch-ok', if (includeFolders) {
scope: 'conversations', ref.invalidate(foldersProvider);
data: {'count': conversations.length}, }
); return;
}
if (ref.read(reviewerModeProvider)) {
state = AsyncData<List<Conversation>>(_demoConversations());
if (includeFolders) {
ref.invalidate(foldersProvider);
}
return;
}
final result = await AsyncValue.guard(_loadRemoteConversations);
if (!ref.mounted) return;
state = result;
if (includeFolders) {
ref.invalidate(foldersProvider);
}
}
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<List<Conversation>>(_sortByUpdatedAt(updated));
}
void upsertConversation(Conversation conversation) {
final current = state.asData?.value ?? const <Conversation>[];
final updated = <Conversation>[...current];
final index = updated.indexWhere(
(element) => element.id == conversation.id,
);
if (index >= 0) {
updated[index] = conversation;
} else {
updated.add(conversation);
}
state = AsyncData<List<Conversation>>(_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 = <Conversation>[...current];
updated[index] = transform(updated[index]);
state = AsyncData<List<Conversation>>(_sortByUpdatedAt(updated));
}
List<Conversation> _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<List<Conversation>> _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 { try {
final foldersData = await api.getFolders(); DebugLogger.log('fetch-start', scope: 'conversations');
final conversations = await api.getConversations();
DebugLogger.log( DebugLogger.log(
'folders-fetched', 'fetch-ok',
scope: 'conversations', scope: 'conversations',
data: {'count': foldersData.length}, data: {'count': conversations.length},
); );
// Parse folder data into Folder objects try {
final folders = foldersData final foldersData = await api.getFolders();
.map((folderData) => Folder.fromJson(folderData))
.toList();
// Create a map of conversation ID to folder ID
final conversationToFolder = <String, String>{};
for (final folder in folders) {
DebugLogger.log( DebugLogger.log(
'folder', 'folders-fetched',
scope: 'conversations/map', scope: 'conversations',
data: { data: {'count': foldersData.length},
'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},
);
}
}
// Update conversations with folder IDs, preferring explicit folder_id from chat if present final folders = foldersData
// Use a map to ensure uniqueness by ID throughout the merge process .map((folderData) => Folder.fromJson(folderData))
final conversationMap = <String, Conversation>{}; .toList();
for (final conversation in conversations) { final conversationToFolder = <String, String>{};
// Prefer server-provided folderId on the chat itself for (final folder in folders) {
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( DebugLogger.log(
'update-folder', 'folder',
scope: 'conversations/map', scope: 'conversations/map',
data: { data: {
'conversationId': conversation.id, 'id': folder.id,
'folderId': folderIdToUse, 'name': folder.name,
'explicit': explicitFolderId != null, '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 = <String, Conversation>{};
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 { } else {
conversationMap[conversation.id] = conversation; DebugLogger.log('folders-synced', scope: 'conversations/map');
} }
}
// Merge conversations that are in folders but missing from the main list for (final folder in folders) {
// Build a set of existing IDs from the fetched list final missingIds = folder.conversationIds
final existingIds = conversationMap.keys.toSet(); .where((id) => !existingIds.contains(id))
.toList();
// Diagnostics: count how many folder-mapped IDs are missing from the main list final hasKnownConversations = conversationMap.values.any(
final missingInBase = conversationToFolder.keys (conversation) => conversation.folderId == folder.id,
.where((id) => !existingIds.contains(id)) );
.toList();
if (missingInBase.isNotEmpty) { final shouldFetchFolder =
DebugLogger.warning( missingIds.isNotEmpty ||
'missing-in-base', (!hasKnownConversations && folder.conversationIds.isEmpty);
scope: 'conversations/map',
data: { List<Conversation> folderConvs = const [];
'count': missingInBase.length, if (shouldFetchFolder) {
'preview': missingInBase.take(5).toList(), 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(
DebugLogger.log('folders-synced', scope: 'conversations/map'); 'sort',
} scope: 'conversations',
data: {'source': 'folder-sync'},
// 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,
); );
_updateCacheTimestamp(DateTime.now());
final shouldFetchFolder = return sortedConversations;
apiSvc != null && } catch (e) {
(missingIds.isNotEmpty || DebugLogger.error(
(!hasKnownConversations && folder.conversationIds.isEmpty)); 'folders-fetch-failed',
scope: 'conversations',
List<Conversation> folderConvs = const []; error: e,
if (shouldFetchFolder) { );
try { final sorted = _sortByUpdatedAt(conversations.toList());
folderConvs = await apiSvc.getConversationsInFolder(folder.id); DebugLogger.log(
DebugLogger.log( 'sort',
'folder-sync', scope: 'conversations',
scope: 'conversations/map', data: {'source': 'fallback'},
data: { );
'folderId': folder.id, _updateCacheTimestamp(DateTime.now());
'fetched': folderConvs.length, return sorted;
'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},
);
}
}
} }
} catch (e, stackTrace) {
// 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) {
DebugLogger.error( DebugLogger.error(
'folders-fetch-failed', 'fetch-failed',
scope: 'conversations', scope: 'conversations',
error: e, error: e,
stackTrace: stackTrace,
); );
// Sort conversations even when folder fetch fails if (e.toString().contains('403')) {
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); DebugLogger.warning('endpoint-403', scope: 'conversations');
DebugLogger.log( }
'sort', return const [];
scope: 'conversations',
data: {'source': 'fallback'},
);
// Update cache timestamp
ref
.read(_conversationsCacheTimestampProvider.notifier)
.set(DateTime.now());
return conversations; // Return original conversations if folder fetch fails
} }
} catch (e, stackTrace) { }
DebugLogger.error(
'fetch-failed',
scope: 'conversations',
error: e,
stackTrace: stackTrace,
);
// If conversations endpoint returns 403, this should now clear auth token List<Conversation> _sortByUpdatedAt(List<Conversation> conversations) {
// and redirect user to login since it's marked as a core endpoint final sorted = [...conversations];
if (e.toString().contains('403')) { sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
DebugLogger.warning('endpoint-403', scope: 'conversations'); return List<Conversation>.unmodifiable(sorted);
} }
// Return empty list instead of re-throwing to allow app to continue functioning void _updateCacheTimestamp(DateTime? timestamp) {
return []; ref.read(_conversationsCacheTimestampProvider.notifier).set(timestamp);
} }
} }

View File

@@ -911,6 +911,26 @@ class ApiService {
return []; return [];
} }
Future<List<Conversation>> _parseConversationSummaryList(
List<dynamic> regular, {
required String debugLabel,
}) async {
final payload = <String, dynamic>{
'regular': List<dynamic>.from(regular),
'pinned': const <dynamic>[],
'archived': const <dynamic>[],
};
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
parseConversationSummariesWorker,
payload,
debugLabel: debugLabel,
);
return parsed
.map((json) => Conversation.fromJson(json))
.toList(growable: false);
}
// Tools - Check available tools on server // Tools - Check available tools on server
Future<List<Map<String, dynamic>>> getAvailableTools() async { Future<List<Map<String, dynamic>>> getAvailableTools() async {
_traceApi('Fetching available tools'); _traceApi('Fetching available tools');
@@ -1005,10 +1025,10 @@ class ApiService {
final response = await _dio.get('/api/v1/chats/folder/$folderId'); final response = await _dio.get('/api/v1/chats/folder/$folderId');
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
return data.whereType<Map>().map((chatData) { return _parseConversationSummaryList(
final map = Map<String, dynamic>.from(chatData); data,
return Conversation.fromJson(parseConversationSummary(map)); debugLabel: 'parse_folder_$folderId',
}).toList(); );
} }
return []; return [];
} }
@@ -1052,10 +1072,7 @@ class ApiService {
final response = await _dio.get('/api/v1/chats/tags/$tag'); final response = await _dio.get('/api/v1/chats/tags/$tag');
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
return data.whereType<Map>().map((chatData) { return _parseConversationSummaryList(data, debugLabel: 'parse_tag_$tag');
final map = Map<String, dynamic>.from(chatData);
return Conversation.fromJson(parseConversationSummary(map));
}).toList();
} }
return []; return [];
} }
@@ -2738,8 +2755,11 @@ class ApiService {
'/api/v1/chats/search', '/api/v1/chats/search',
queryParameters: {'q': query}, queryParameters: {'q': query},
); );
final results = response.data as List; final results = response.data;
return results.map((c) => Conversation.fromJson(c)).toList(); if (results is List) {
return _parseConversationSummaryList(results, debugLabel: 'parse_search');
}
return [];
} }
// Debug method to test API endpoints // Debug method to test API endpoints

View File

@@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart'; import '../persistence/persistence_keys.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
import 'secure_credential_storage.dart'; import 'secure_credential_storage.dart';
import 'worker_manager.dart';
/// Optimized storage service backed by Hive for non-sensitive data and /// Optimized storage service backed by Hive for non-sensitive data and
/// FlutterSecureStorage for credentials. /// FlutterSecureStorage for credentials.
@@ -16,19 +17,22 @@ class OptimizedStorageService {
OptimizedStorageService({ OptimizedStorageService({
required FlutterSecureStorage secureStorage, required FlutterSecureStorage secureStorage,
required HiveBoxes boxes, required HiveBoxes boxes,
required WorkerManager workerManager,
}) : _preferencesBox = boxes.preferences, }) : _preferencesBox = boxes.preferences,
_cachesBox = boxes.caches, _cachesBox = boxes.caches,
_attachmentQueueBox = boxes.attachmentQueue, _attachmentQueueBox = boxes.attachmentQueue,
_metadataBox = boxes.metadata, _metadataBox = boxes.metadata,
_secureCredentialStorage = SecureCredentialStorage( _secureCredentialStorage = SecureCredentialStorage(
instance: secureStorage, instance: secureStorage,
); ),
_workerManager = workerManager;
final Box<dynamic> _preferencesBox; final Box<dynamic> _preferencesBox;
final Box<dynamic> _cachesBox; final Box<dynamic> _cachesBox;
final Box<dynamic> _attachmentQueueBox; final Box<dynamic> _attachmentQueueBox;
final Box<dynamic> _metadataBox; final Box<dynamic> _metadataBox;
final SecureCredentialStorage _secureCredentialStorage; final SecureCredentialStorage _secureCredentialStorage;
final WorkerManager _workerManager;
static const String _authTokenKey = 'auth_token_v3'; static const String _authTokenKey = 'auth_token_v3';
static const String _activeServerIdKey = PreferenceKeys.activeServerId; static const String _activeServerIdKey = PreferenceKeys.activeServerId;
@@ -298,19 +302,13 @@ class OptimizedStorageService {
if (stored == null) { if (stored == null) {
return const []; return const [];
} }
if (stored is String) { final parsed = await _workerManager
final decoded = jsonDecode(stored) as List<dynamic>; .schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
return decoded.map((item) => Conversation.fromJson(item)).toList(); _decodeStoredConversationsWorker,
} {'stored': stored},
if (stored is List) { debugLabel: 'decode_local_conversations',
return stored );
.map( return parsed.map(Conversation.fromJson).toList(growable: false);
(item) =>
Conversation.fromJson(Map<String, dynamic>.from(item as Map)),
)
.toList();
}
return const [];
} catch (error, stack) { } catch (error, stack) {
DebugLogger.error( DebugLogger.error(
'Failed to retrieve local conversations', 'Failed to retrieve local conversations',
@@ -455,3 +453,28 @@ class OptimizedStorageService {
}; };
} }
} }
List<Map<String, dynamic>> _decodeStoredConversationsWorker(
Map<String, dynamic> payload,
) {
final stored = payload['stored'];
if (stored is String) {
final decoded = jsonDecode(stored);
if (decoded is List) {
return decoded
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
return <Map<String, dynamic>>[];
}
if (stored is List) {
return stored
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
return <Map<String, dynamic>>[];
}

View File

@@ -170,12 +170,14 @@ class WorkerManager {
/// Keep a single [WorkerManager] alive across the app. /// Keep a single [WorkerManager] alive across the app.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
// ignore: functional_ref class WorkerManagerNotifier extends _$WorkerManagerNotifier {
WorkerManager workerManager(Ref ref) { @override
final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks; WorkerManager build() {
final manager = WorkerManager(maxConcurrentTasks: concurrency); final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks;
ref.onDispose(manager.dispose); final manager = WorkerManager(maxConcurrentTasks: concurrency);
return manager; ref.onDispose(manager.dispose);
return manager;
}
} }
class _EnqueuedJob { class _EnqueuedJob {

View File

@@ -719,9 +719,40 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
_messageStream = null; _messageStream = null;
_stopRemoteTaskMonitor(); _stopRemoteTaskMonitor();
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation != null) {
final updatedActive = activeConversation.copyWith(
messages: List<ChatMessage>.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 // Trigger a refresh of the conversations list so UI like the Chats Drawer
// can pick up updated titles and ordering once streaming completes. // can reconcile with the server once streaming completes. Best-effort:
// Best-effort: ignore if ref lifecycle/context prevents invalidation. // ignore if ref lifecycle/context prevents invalidation.
try { try {
refreshConversationsCache(ref); refreshConversationsCache(ref);
} catch (_) {} } catch (_) {}
@@ -1480,6 +1511,15 @@ Future<void> regenerateMessage(
ref ref
.read(activeConversationProvider.notifier) .read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle)); .set(active.copyWith(title: newTitle));
ref
.read(conversationsProvider.notifier)
.updateConversation(
active.id,
(conversation) => conversation.copyWith(
title: newTitle,
updatedAt: DateTime.now(),
),
);
} }
refreshConversationsCache(ref); refreshConversationsCache(ref);
}, },
@@ -1492,6 +1532,9 @@ Future<void> regenerateMessage(
try { try {
final refreshed = await api.getConversation(active.id); final refreshed = await api.getConversation(active.id);
ref.read(activeConversationProvider.notifier).set(refreshed); ref.read(activeConversationProvider.notifier).set(refreshed);
ref
.read(conversationsProvider.notifier)
.upsertConversation(refreshed.copyWith(messages: const []));
} catch (_) {} } catch (_) {}
}); });
} }
@@ -1625,6 +1668,12 @@ Future<void> _sendMessageInternal(
ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).clearMessages();
ref.read(chatMessagesProvider.notifier).addMessage(userMessage); ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
ref
.read(conversationsProvider.notifier)
.upsertConversation(
updatedConversation.copyWith(updatedAt: DateTime.now()),
);
// Invalidate conversations provider to refresh the list // Invalidate conversations provider to refresh the list
// Adding a small delay to prevent rapid invalidations that could cause duplicates // Adding a small delay to prevent rapid invalidations that could cause duplicates
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
@@ -2029,6 +2078,15 @@ Future<void> _sendMessageInternal(
ref ref
.read(activeConversationProvider.notifier) .read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle)); .set(active.copyWith(title: newTitle));
ref
.read(conversationsProvider.notifier)
.updateConversation(
active.id,
(conversation) => conversation.copyWith(
title: newTitle,
updatedAt: DateTime.now(),
),
);
} }
refreshConversationsCache(ref); refreshConversationsCache(ref);
}, },
@@ -2041,6 +2099,9 @@ Future<void> _sendMessageInternal(
try { try {
final refreshed = await api.getConversation(active.id); final refreshed = await api.getConversation(active.id);
ref.read(activeConversationProvider.notifier).set(refreshed); ref.read(activeConversationProvider.notifier).set(refreshed);
ref
.read(conversationsProvider.notifier)
.upsertConversation(refreshed.copyWith(messages: const []));
} catch (_) {} } catch (_) {}
}); });
} }
@@ -2204,6 +2265,14 @@ Future<void> pinConversation(
await api.pinConversation(conversationId, pinned); 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 // Refresh conversations list to reflect the change
refreshConversationsCache(ref); refreshConversationsCache(ref);
@@ -2243,6 +2312,16 @@ Future<void> archiveConversation(
await api.archiveConversation(conversationId, archived); 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 // Refresh conversations list to reflect the change
refreshConversationsCache(ref); refreshConversationsCache(ref);
} catch (e) { } catch (e) {
@@ -2269,6 +2348,16 @@ Future<String?> shareConversation(WidgetRef ref, String conversationId) async {
final shareId = await api.shareConversation(conversationId); 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 // Refresh conversations list to reflect the change
refreshConversationsCache(ref); refreshConversationsCache(ref);
@@ -2293,6 +2382,11 @@ Future<void> cloneConversation(WidgetRef ref, String conversationId) async {
// The ChatMessagesNotifier will automatically load messages when activeConversation changes // The ChatMessagesNotifier will automatically load messages when activeConversation changes
// Refresh conversations list to show the new conversation // Refresh conversations list to show the new conversation
ref
.read(conversationsProvider.notifier)
.upsertConversation(
clonedConversation.copyWith(updatedAt: DateTime.now()),
);
refreshConversationsCache(ref); refreshConversationsCache(ref);
} catch (e) { } catch (e) {
DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers'); DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers');

View File

@@ -813,6 +813,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, folderId); await api.moveConversationToFolder(details.data.id, folderId);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
details.data.id,
(conversation) => conversation.copyWith(
folderId: folderId,
updatedAt: DateTime.now(),
),
);
refreshConversationsCache(ref, includeFolders: true); refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) { } catch (e, stackTrace) {
DebugLogger.error( DebugLogger.error(
@@ -1153,6 +1162,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, null); await api.moveConversationToFolder(details.data.id, null);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
details.data.id,
(conversation) => conversation.copyWith(
folderId: null,
updatedAt: DateTime.now(),
),
);
refreshConversationsCache(ref, includeFolders: true); refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) { } catch (e, stackTrace) {
DebugLogger.error( DebugLogger.error(

View File

@@ -221,6 +221,13 @@ Future<void> _renameConversation(
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName); await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref
.read(conversationsProvider.notifier)
.updateConversation(
conversationId,
(conversation) =>
conversation.copyWith(title: newName, updatedAt: DateTime.now()),
);
refreshConversationsCache(ref); refreshConversationsCache(ref);
final active = ref.read(activeConversationProvider); final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) { if (active?.id == conversationId) {
@@ -257,6 +264,7 @@ Future<void> _confirmAndDeleteConversation(
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.deleteConversation(conversationId); await api.deleteConversation(conversationId);
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
ref.read(conversationsProvider.notifier).removeConversation(conversationId);
final active = ref.read(activeConversationProvider); final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) { if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).clear(); ref.read(activeConversationProvider.notifier).clear();