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;