feat(folders): Improve folder management and parsing logic
This commit is contained in:
@@ -890,6 +890,10 @@ void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
|
|||||||
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
|
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
|
||||||
final notifier = ref.read(conversationsProvider.notifier);
|
final notifier = ref.read(conversationsProvider.notifier);
|
||||||
unawaited(notifier.refresh(includeFolders: includeFolders));
|
unawaited(notifier.refresh(includeFolders: includeFolders));
|
||||||
|
if (includeFolders) {
|
||||||
|
final foldersNotifier = ref.read(foldersProvider.notifier);
|
||||||
|
unawaited(foldersNotifier.refresh());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversation providers - Now using correct OpenWebUI API with caching and
|
// Conversation providers - Now using correct OpenWebUI API with caching and
|
||||||
@@ -918,7 +922,7 @@ class Conversations extends _$Conversations {
|
|||||||
_updateCacheTimestamp(null);
|
_updateCacheTimestamp(null);
|
||||||
state = AsyncData<List<Conversation>>(<Conversation>[]);
|
state = AsyncData<List<Conversation>>(<Conversation>[]);
|
||||||
if (includeFolders) {
|
if (includeFolders) {
|
||||||
ref.invalidate(foldersProvider);
|
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -926,7 +930,7 @@ class Conversations extends _$Conversations {
|
|||||||
if (ref.read(reviewerModeProvider)) {
|
if (ref.read(reviewerModeProvider)) {
|
||||||
state = AsyncData<List<Conversation>>(_demoConversations());
|
state = AsyncData<List<Conversation>>(_demoConversations());
|
||||||
if (includeFolders) {
|
if (includeFolders) {
|
||||||
ref.invalidate(foldersProvider);
|
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -935,7 +939,7 @@ class Conversations extends _$Conversations {
|
|||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
state = result;
|
state = result;
|
||||||
if (includeFolders) {
|
if (includeFolders) {
|
||||||
ref.invalidate(foldersProvider);
|
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1823,32 +1827,94 @@ final webSearchAvailableProvider = Provider<bool>((ref) {
|
|||||||
|
|
||||||
// Folders provider
|
// Folders provider
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Future<List<Folder>> folders(Ref ref) async {
|
class Folders extends _$Folders {
|
||||||
// Protected: require authentication
|
@override
|
||||||
if (!ref.read(isAuthenticatedProvider2)) {
|
Future<List<Folder>> build() async {
|
||||||
DebugLogger.log('skip-unauthed', scope: 'folders');
|
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||||
return [];
|
DebugLogger.log('skip-unauthed', scope: 'folders');
|
||||||
}
|
return const [];
|
||||||
final api = ref.watch(apiServiceProvider);
|
}
|
||||||
if (api == null) {
|
final api = ref.watch(apiServiceProvider);
|
||||||
DebugLogger.warning('api-missing', scope: 'folders');
|
if (api == null) {
|
||||||
return [];
|
DebugLogger.warning('api-missing', scope: 'folders');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return _load(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
Future<void> refresh() async {
|
||||||
final foldersData = await api.getFolders();
|
if (!ref.read(isAuthenticatedProvider2)) {
|
||||||
final folders = foldersData
|
state = const AsyncData<List<Folder>>([]);
|
||||||
.map((folderData) => Folder.fromJson(folderData))
|
return;
|
||||||
.toList();
|
}
|
||||||
DebugLogger.log(
|
final api = ref.read(apiServiceProvider);
|
||||||
'fetch-ok',
|
if (api == null) {
|
||||||
scope: 'folders',
|
state = const AsyncData<List<Folder>>([]);
|
||||||
data: {'count': folders.length},
|
return;
|
||||||
);
|
}
|
||||||
return folders;
|
final result = await AsyncValue.guard(() => _load(api));
|
||||||
} catch (e) {
|
if (!ref.mounted) return;
|
||||||
DebugLogger.error('fetch-failed', scope: 'folders', error: e);
|
state = result;
|
||||||
return [];
|
}
|
||||||
|
|
||||||
|
void upsertFolder(Folder folder) {
|
||||||
|
final current = state.asData?.value ?? const <Folder>[];
|
||||||
|
final updated = <Folder>[...current];
|
||||||
|
final index = updated.indexWhere((existing) => existing.id == folder.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
updated[index] = folder;
|
||||||
|
} else {
|
||||||
|
updated.add(folder);
|
||||||
|
}
|
||||||
|
state = AsyncData<List<Folder>>(_sort(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFolder(String id, Folder Function(Folder folder) transform) {
|
||||||
|
final current = state.asData?.value;
|
||||||
|
if (current == null) return;
|
||||||
|
final index = current.indexWhere((folder) => folder.id == id);
|
||||||
|
if (index < 0) return;
|
||||||
|
final updated = <Folder>[...current];
|
||||||
|
updated[index] = transform(updated[index]);
|
||||||
|
state = AsyncData<List<Folder>>(_sort(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFolder(String id) {
|
||||||
|
final current = state.asData?.value;
|
||||||
|
if (current == null) return;
|
||||||
|
final updated = current
|
||||||
|
.where((folder) => folder.id != id)
|
||||||
|
.toList(growable: true);
|
||||||
|
state = AsyncData<List<Folder>>(_sort(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Folder>> _load(ApiService api) async {
|
||||||
|
try {
|
||||||
|
final foldersData = await api.getFolders();
|
||||||
|
final folders = foldersData
|
||||||
|
.map((folderData) => Folder.fromJson(folderData))
|
||||||
|
.toList();
|
||||||
|
DebugLogger.log(
|
||||||
|
'fetch-ok',
|
||||||
|
scope: 'folders',
|
||||||
|
data: {'count': folders.length},
|
||||||
|
);
|
||||||
|
return _sort(folders);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
DebugLogger.error(
|
||||||
|
'fetch-failed',
|
||||||
|
scope: 'folders',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Folder> _sort(List<Folder> input) {
|
||||||
|
final sorted = [...input];
|
||||||
|
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return List<Folder>.unmodifiable(sorted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3003,23 +3003,23 @@ class ApiService {
|
|||||||
);
|
);
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
// The endpoint can return a List[ChatTitleIdResponse] or a map.
|
// The endpoint can return a List[ChatTitleIdResponse] or a map.
|
||||||
// Normalize to a List<Conversation> using our safe parser.
|
// Normalize to a List<Conversation> using our isolate parser.
|
||||||
if (data is List) {
|
if (data is List) {
|
||||||
return data
|
return _parseConversationSummaryList(
|
||||||
.whereType<Map<String, dynamic>>()
|
data,
|
||||||
.map((e) => Conversation.fromJson(parseConversationSummary(e)))
|
debugLabel: 'parse_search_direct',
|
||||||
.toList();
|
);
|
||||||
}
|
}
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
final list = (data['conversations'] ?? data['items'] ?? data['results']);
|
final list = (data['conversations'] ?? data['items'] ?? data['results']);
|
||||||
if (list is List) {
|
if (list is List) {
|
||||||
return list
|
return _parseConversationSummaryList(
|
||||||
.whereType<Map<String, dynamic>>()
|
list,
|
||||||
.map((e) => Conversation.fromJson(parseConversationSummary(e)))
|
debugLabel: 'parse_search_wrapped',
|
||||||
.toList();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <Conversation>[];
|
return const <Conversation>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search within messages content (capability-safe)
|
/// Search within messages content (capability-safe)
|
||||||
|
|||||||
@@ -322,9 +322,13 @@ class OptimizedStorageService {
|
|||||||
|
|
||||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||||
try {
|
try {
|
||||||
final serialized = conversations
|
final jsonReady = conversations
|
||||||
.map((conversation) => conversation.toJson())
|
.map((conversation) => conversation.toJson())
|
||||||
.toList();
|
.toList();
|
||||||
|
final serialized = await _workerManager
|
||||||
|
.schedule<Map<String, dynamic>, String>(_encodeConversationsWorker, {
|
||||||
|
'conversations': jsonReady,
|
||||||
|
}, debugLabel: 'encode_local_conversations');
|
||||||
await _cachesBox.put(_localConversationsKey, serialized);
|
await _cachesBox.put(_localConversationsKey, serialized);
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Saved ${conversations.length} local conversations',
|
'Saved ${conversations.length} local conversations',
|
||||||
@@ -478,3 +482,15 @@ List<Map<String, dynamic>> _decodeStoredConversationsWorker(
|
|||||||
|
|
||||||
return <Map<String, dynamic>>[];
|
return <Map<String, dynamic>>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _encodeConversationsWorker(Map<String, dynamic> payload) {
|
||||||
|
final raw = payload['conversations'];
|
||||||
|
if (raw is List) {
|
||||||
|
return jsonEncode(raw);
|
||||||
|
}
|
||||||
|
if (raw is String) {
|
||||||
|
// Already encoded.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return jsonEncode([]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -770,8 +770,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api == null) throw Exception('No API service');
|
if (api == null) throw Exception('No API service');
|
||||||
await api.createFolder(name: name);
|
final created = await api.createFolder(name: name);
|
||||||
|
final folder = Folder.fromJson(Map<String, dynamic>.from(created));
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
ref.read(foldersProvider.notifier).upsertFolder(folder);
|
||||||
refreshConversationsCache(ref, includeFolders: true);
|
refreshConversationsCache(ref, includeFolders: true);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1094,6 +1096,13 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
if (api == null) throw Exception('No API service');
|
if (api == null) throw Exception('No API service');
|
||||||
await api.updateFolder(folderId, name: newName);
|
await api.updateFolder(folderId, name: newName);
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
|
ref
|
||||||
|
.read(foldersProvider.notifier)
|
||||||
|
.updateFolder(
|
||||||
|
folderId,
|
||||||
|
(folder) =>
|
||||||
|
folder.copyWith(name: newName, updatedAt: DateTime.now()),
|
||||||
|
);
|
||||||
refreshConversationsCache(ref, includeFolders: true);
|
refreshConversationsCache(ref, includeFolders: true);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1129,6 +1138,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
if (api == null) throw Exception('No API service');
|
if (api == null) throw Exception('No API service');
|
||||||
await api.deleteFolder(folderId);
|
await api.deleteFolder(folderId);
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
|
ref.read(foldersProvider.notifier).removeFolder(folderId);
|
||||||
refreshConversationsCache(ref, includeFolders: true);
|
refreshConversationsCache(ref, includeFolders: true);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user