@tduncan / Cortana / commits / 80728fd

fix: update data backup schema, improve chat stream handling, and fix search state synchronization

abdulmominsakib committed Jul 5, 2026 at 10:55 UTC 80728fdb28a187f6d38fea3b19f3e7cc35e7ddf9
11 files changed +116 -26
lib/core/services/data_backup_service.dart
+53 -2
@@ -58,6 +58,10 @@ class DataBackupService {
58 'smartReplies': domain.smartReplies,
59 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
60 'folderId': domain.folderId,
61 + 'isTemporary': domain.isTemporary,
62 + 'isArchived': domain.isArchived,
63 + 'characterCount': domain.characterCount,
64 + 'totalTokenCount': domain.totalTokenCount,
65 };
66
67 List<Map<String, dynamic>> _exportSavedMessages(Store store) {
@@ -72,11 +76,24 @@ class DataBackupService {
76 'content': e.content,
77 'modelId': e.modelId,
78 'folderId': e.folderId,
79 + 'isArchived': e.isArchived,
80 'savedAt': e.savedAt.toIso8601String(),
81 };
82 }).toList();
83 }
84
85 + List<Map<String, dynamic>> _exportConversationFolders(Store store) {
86 + final box = store.box<ConversationFolderEntity>();
87 + return box.getAll().map((e) {
88 + return {
89 + 'id': e.id,
90 + 'name': e.name,
91 + 'sortOrder': e.sortOrder,
92 + 'createdAt': e.createdAt.toIso8601String(),
93 + };
94 + }).toList();
95 + }
96 +
97 List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
98 final box = store.box<SavedMessageFolderEntity>();
99 return box.getAll().map((e) {
@@ -160,7 +177,8 @@ class DataBackupService {
177 'savedMessages': _exportSavedMessages(store),
178 'savedMessageFolders': _exportSavedMessageFolders(store),
179 'servers': _exportServers(store),
163 - 'modelMetadata': ?modelMetadata,
180 + 'conversationFolders': _exportConversationFolders(store),
181 + 'modelMetadata': modelMetadata,
182 };
183 }
184
@@ -355,6 +373,7 @@ class DataBackupService {
373 final serverBox = store.box<ServerEntity>();
374 final savedBox = store.box<SavedMessageEntity>();
375 final savedFolderBox = store.box<SavedMessageFolderEntity>();
376 + final conversationFolderBox = store.box<ConversationFolderEntity>();
377
378 final personas = decoded['personas'];
379 if (personas is List) {
@@ -477,6 +496,7 @@ class DataBackupService {
496 content: item['content'] as String? ?? '',
497 modelId: item['modelId'] as String?,
498 folderId: item['folderId'] as String?,
499 + isArchived: item['isArchived'] as bool? ?? false,
500 savedAt: savedAt,
501 );
502 final query =
@@ -488,6 +508,30 @@ class DataBackupService {
508 }
509 }
510
511 + final conversationFolders = decoded['conversationFolders'];
512 + if (conversationFolders is List) {
513 + for (final item in conversationFolders) {
514 + if (item is! Map) continue;
515 + final id = backupImportString(item['id']);
516 + final folderName = backupImportString(item['name']);
517 + final createdAt = backupImportDateTime(item['createdAt']);
518 + if (id == null || folderName == null || createdAt == null) continue;
519 + final entity = ConversationFolderEntity(
520 + id: id,
521 + name: folderName,
522 + sortOrder: item['sortOrder'] as int? ?? 0,
523 + createdAt: createdAt,
524 + );
525 + final query = conversationFolderBox
526 + .query(ConversationFolderEntity_.id.equals(entity.id))
527 + .build();
528 + final existing = query.findFirst();
529 + query.close();
530 + if (existing != null) entity.internalId = existing.internalId;
531 + conversationFolderBox.put(entity);
532 + }
533 + }
534 +
535 final conversations = decoded['conversations'];
536 if (conversations is List) {
537 for (final item in conversations) {
@@ -519,6 +563,10 @@ class DataBackupService {
563 smartRepliesLastMessageId:
564 item['smartRepliesLastMessageId'] as String?,
565 folderId: item['folderId'] as String?,
566 + isTemporary: item['isTemporary'] as bool? ?? false,
567 + isArchived: item['isArchived'] as bool? ?? false,
568 + characterCount: item['characterCount'] as int? ?? 0,
569 + totalTokenCount: item['totalTokenCount'] as int?,
570 );
571 final query =
572 convBox.query(ConversationEntity_.id.equals(conversation.id)).build();
@@ -603,8 +651,11 @@ class DataBackupService {
651 Future<void> importZip(Store store, List<int> bytes) async {
652 final archive = ZipDecoder().decodeBytes(bytes);
653 for (final file in archive.files) {
606 - if (file.isFile) {
654 + if (!file.isFile) continue;
655 + try {
656 await importFromJson(store, utf8.decode(file.content as List<int>));
657 + } catch (_) {
658 + // skip corrupted entry
659 }
660 }
661 }
lib/features/chat/providers/chat_notifier.dart
+11 -2
@@ -807,7 +807,7 @@ class ChatNotifier extends Notifier<ChatState> {
807 final messagesForApi = _buildMessagesForApi(selectedModel);
808
809 try {
810 - await _detachStreamSubscription();
810 + await _abortStreamImmediately();
811 _uiUpdateTimer?.cancel();
812
813 String reasoningContent = '';
@@ -1580,7 +1580,11 @@ class ChatNotifier extends Notifier<ChatState> {
1580 query.close();
1581 }
1582
1583 + final removedIds = messagesToRemove.map((m) => m.id).toSet();
1584 + final updatedAll =
1585 + state.allMessages.where((m) => !removedIds.contains(m.id)).toList();
1586 state = state.copyWith(
1587 + allMessages: updatedAll,
1588 messages: messages.sublist(0, userMessageIndex),
1589 clearStreaming: true,
1590 );
@@ -1604,8 +1608,12 @@ class ChatNotifier extends Notifier<ChatState> {
1608 status: MessageStatus.streaming,
1609 isProcessing: true,
1610 );
1607 - _replaceMessageInAll(streamingAssistant);
1611 + final updatedAll = state.allMessages.map((m) {
1612 + return m.id == streamingAssistant.id ? streamingAssistant : m;
1613 + }).toList();
1614 state = state.copyWith(
1615 + allMessages: updatedAll,
1616 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
1617 isStreaming: true,
1618 streamingMessage: streamingAssistant,
1619 clearError: true,
@@ -2274,6 +2282,7 @@ class ChatNotifier extends Notifier<ChatState> {
2282 }
2283
2284 Future<void> clearConversation() async {
2285 + await _saveService?.flush();
2286 final db = ref.read(databaseProvider);
2287 if (_currentConversationId != null && !_isInMemoryChat) {
2288 final query = db.messageBox
lib/features/chat/utils/image_upload_utils.dart
+15 -2
@@ -71,10 +71,23 @@ class ImageUploadUtils {
71 Uint8List bytes,
72 int maxDimension,
73 ) async {
74 + final decodeCodec = await ui.instantiateImageCodec(bytes);
75 + final decodeFrame = await decodeCodec.getNextFrame();
76 + final origWidth = decodeFrame.image.width;
77 + final origHeight = decodeFrame.image.height;
78 + decodeFrame.image.dispose();
79 + int targetWidth, targetHeight;
80 + if (origWidth >= origHeight) {
81 + targetWidth = maxDimension;
82 + targetHeight = (origHeight * maxDimension / origWidth).round().clamp(1, maxDimension);
83 + } else {
84 + targetHeight = maxDimension;
85 + targetWidth = (origWidth * maxDimension / origHeight).round().clamp(1, maxDimension);
86 + }
87 final codec = await ui.instantiateImageCodec(
88 bytes,
76 - targetWidth: maxDimension,
77 - targetHeight: maxDimension,
89 + targetWidth: targetWidth,
90 + targetHeight: targetHeight,
91 );
92 final frame = await codec.getNextFrame();
93 final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
lib/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart
+5 -4
@@ -16,10 +16,11 @@ final _currencyLike = RegExp(r'^\s*\d[\d,]*(\.\d+)?\s*$');
16 String normalizeDollarLatex(String input) {
17 if (!input.contains(r'$')) return input;
18
19 - var result = input.replaceAllMapped(
20 - _blockDollarLatex,
21 - (m) => '\\[${m[1]!.trim()}\\]',
22 - );
19 + var result = input.replaceAllMapped(_blockDollarLatex, (m) {
20 + final inner = m[1]!.trim();
21 + if (inner.isEmpty || _currencyLike.hasMatch(inner)) return m[0]!;
22 + return '\\[$inner\\]';
23 + });
24
25 result = result.replaceAllMapped(_inlineDollarLatex, (m) {
26 final inner = m[1]!;
lib/features/chat/views/components/chat_input_bar.dart
+1 -1
@@ -458,7 +458,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
458
459 onResult: (words) {
460
461 - if (words.isNotEmpty) {
461 + if (words.isNotEmpty && mounted) {
462
463 setState(() {
464
lib/features/chat/views/components/message_list/message_list.dart
+1 -6
@@ -156,11 +156,6 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
156 }
157 });
158
159 - final pendingScroll = ref.watch(scrollToMessageIdProvider);
160 - if (pendingScroll != null) {
161 - _scrollToMessage(pendingScroll);
162 - }
163 -
159 final streamingMessage = ref.watch(
160 chatProvider.select((s) => s.streamingMessage),
161 );
@@ -293,7 +288,7 @@ class _MessageList extends ConsumerWidget {
288 final bubble = ChatBubble(
289 key: itemKey,
290 message: message,
296 - allMessages: this.allMessages,
291 + allMessages: allMessages,
292 isStreaming:
293 isLast &&
294 showTrailingStreamingBubble &&
lib/features/conversations/views/components/conversation_list.dart
+9 -4
@@ -40,7 +40,8 @@ class ConversationList extends ConsumerWidget {
40 ..sort((a, b) {
41 final aIndex = sectionOrder.indexOf(a);
42 final bIndex = sectionOrder.indexOf(b);
43 - return aIndex.compareTo(bIndex);
43 + return (aIndex == -1 ? 999 : aIndex)
44 + .compareTo(bIndex == -1 ? 999 : bIndex);
45 });
46
47 return ListView.builder(
@@ -355,9 +356,13 @@ Future<void> runBulkAiRename(
356 );
357
358 for (final id in conversationIds) {
358 - final title = await ref.read(chatProvider.notifier).generateTitleWithAi(id);
359 - if (title != null && title.isNotEmpty) {
360 - await ref.read(conversationsProvider.notifier).renameConversation(id, title);
359 + try {
360 + final title = await ref.read(chatProvider.notifier).generateTitleWithAi(id);
361 + if (title != null && title.isNotEmpty) {
362 + await ref.read(conversationsProvider.notifier).renameConversation(id, title);
363 + }
364 + } catch (_) {
365 + // continue renaming remaining conversations
366 }
367 progress.value += 1;
368 }
lib/features/lm_studio_catalog/data/lm_studio_download_service.dart
+5 -4
@@ -70,15 +70,16 @@ class LmStudioDownloadService {
70 ),
71 );
72
73 - final data = response.data ?? {};
74 - if (response.statusCode != 200 || data['error'] != null) {
75 - final error = data['error'];
73 + if (response.statusCode != 200) {
74 + final data = response.data;
75 + final error = data is Map<String, dynamic> ? data['error'] : null;
76 final message = error is Map
77 ? error['message']?.toString()
78 : error?.toString();
79 - throw Exception(message ?? 'Download failed');
79 + throw Exception(message ?? 'Download failed (status ${response.statusCode})');
80 }
81
82 + final data = response.data ?? <String, dynamic>{};
83 return LmDownloadJob.fromJson(
84 data,
85 modelId: request.model,
lib/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart
+6
@@ -71,6 +71,7 @@ class LmCatalogSearchState {
71
72 class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
73 String _query = '';
74 + int _searchGeneration = 0;
75
76 @override
77 LmCatalogSearchState build() => const LmCatalogSearchState();
@@ -87,14 +88,17 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
88 return;
89 }
90
91 + final generation = ++_searchGeneration;
92 state = state.copyWith(isLoading: true, clearError: true);
93 final service = ref.read(lmStudioCatalogServiceProvider);
94
95 try {
96 final staffPicks = await ref.read(lmStudioStaffPicksProvider.future);
97 + if (generation != _searchGeneration) return;
98 final staffMatches =
99 staffPicks.where((m) => m.matchesQuery(trimmed)).toList();
100 final page = await service.searchHuggingFace(query: trimmed);
101 + if (generation != _searchGeneration) return;
102 final staffIds = staffMatches.map((m) => m.id).toSet();
103 final community =
104 page.models.where((m) => !staffIds.contains(m.id)).toList();
@@ -107,6 +111,7 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
111 isLoading: false,
112 );
113 } catch (e) {
114 + if (generation != _searchGeneration) return;
115 state = state.copyWith(isLoading: false, error: e.toString());
116 }
117 }
@@ -434,6 +439,7 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
439 timer.cancel();
440 }
441 _pollers.clear();
442 + _jobServerIds.clear();
443 }
444 }
445
lib/features/personas/providers/personas_providers.dart
+9
@@ -149,6 +149,15 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
149 db.personaBox.removeMany(query.findIds());
150 query.close();
151
152 + final convQuery = db.conversationBox
153 + .query(ConversationEntity_.personaId.equals(id))
154 + .build();
155 + for (final entity in convQuery.find()) {
156 + entity.personaId = null;
157 + db.conversationBox.put(entity);
158 + }
159 + convQuery.close();
160 +
161 state = AsyncData(await _loadWithoutSeeding());
162 }
163
lib/features/settings/data/models/app_settings.dart
+1 -1
@@ -320,7 +320,7 @@ class AppSettings {
320 if (value is String) {
321 return engineIdFromString(value);
322 }
323 - if (value is int) {
323 + if (value is int && value >= 0 && value < EngineId.values.length) {
324 return EngineId.values[value];
325 }
326 return EngineId.system;