feat(api): Optimize conversation parsing with worker-based decoding
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>>[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user