@tduncan / Cortana / commits / 18e4796

Fix backup import crashes, stream races, and search performance issues.

Harden backup import with null-safe parsing, guard stream callbacks after reset, fix retry-last-message logic, and reduce memory use in message search. Co-authored-by: Cursor <cursoragent@cursor.com>

onlypuppy7 committed Jul 1, 2026 at 10:40 UTC 18e4796c442961ff303e8e1eea4b6b8f94193d40
10 files changed +228 -396
lib/core/services/data_backup_service.dart
+85 -24
@@ -147,6 +147,7 @@ class DataBackupService {
147 })
148 .toList();
149
150 + final modelMetadata = _readModelMetadata(prefs);
151 return {
152 'version': 3,
153 'exportedAt': DateTime.now().toIso8601String(),
@@ -156,8 +157,7 @@ class DataBackupService {
157 'savedMessages': _exportSavedMessages(store),
158 'savedMessageFolders': _exportSavedMessageFolders(store),
159 'servers': _exportServers(store),
159 - if (_readModelMetadata(prefs) != null)
160 - 'modelMetadata': _readModelMetadata(prefs),
160 + if (modelMetadata != null) 'modelMetadata': modelMetadata,
161 };
162 }
163
@@ -185,6 +185,13 @@ class DataBackupService {
185 final savedMessages = (all['savedMessages'] as List)
186 .where((m) => (m as Map)['conversationId'] == conversationId)
187 .toList();
188 + final folderIds = savedMessages
189 + .map((m) => (m as Map)['folderId'] as String?)
190 + .whereType<String>()
191 + .toSet();
192 + final savedMessageFolders = (all['savedMessageFolders'] as List)
193 + .where((f) => folderIds.contains((f as Map)['id']))
194 + .toList();
195
196 return {
197 'version': 3,
@@ -193,6 +200,7 @@ class DataBackupService {
200 'conversations': conversations,
201 'messages': messages,
202 'savedMessages': savedMessages,
203 + 'savedMessageFolders': savedMessageFolders,
204 };
205 }
206
@@ -302,15 +310,22 @@ class DataBackupService {
310 if (personas is List) {
311 for (final item in personas) {
312 if (item is! Map) continue;
313 + final id = _backupString(item['id']);
314 + final name = _backupString(item['name']);
315 + final createdAt = _backupDateTime(item['createdAt']);
316 + final updatedAt = _backupDateTime(item['updatedAt']);
317 + if (id == null || name == null || createdAt == null || updatedAt == null) {
318 + continue;
319 + }
320 final persona = Persona(
306 - id: item['id'] as String,
307 - name: item['name'] as String,
321 + id: id,
322 + name: name,
323 emoji: item['emoji'] as String? ?? '🤖',
324 systemPrompt: item['systemPrompt'] as String? ?? '',
325 description: item['description'] as String?,
326 isBuiltIn: item['isBuiltIn'] as bool? ?? false,
312 - createdAt: DateTime.parse(item['createdAt'] as String),
313 - updatedAt: DateTime.parse(item['updatedAt'] as String),
327 + createdAt: createdAt,
328 + updatedAt: updatedAt,
329 category: item['category'] as String?,
330 preferredParams: item['preferredParams'] is Map
331 ? Map<String, dynamic>.from(item['preferredParams'] as Map)
@@ -330,16 +345,26 @@ class DataBackupService {
345 if (servers is List) {
346 for (final item in servers) {
347 if (item is! Map) continue;
348 + final id = _backupString(item['id']);
349 + final name = _backupString(item['name']);
350 + final createdAt = _backupDateTime(item['createdAt']);
351 + final lastConnectedAt = _backupDateTime(item['lastConnectedAt']);
352 + if (id == null ||
353 + name == null ||
354 + createdAt == null ||
355 + lastConnectedAt == null) {
356 + continue;
357 + }
358 final server = Server(
334 - id: item['id'] as String,
335 - name: item['name'] as String,
359 + id: id,
360 + name: name,
361 type: ServerType.values.byName(item['type'] as String? ?? 'lmStudio'),
362 host: item['host'] as String? ?? 'localhost',
363 port: item['port'] as int? ?? 1234,
364 apiKey: item['apiKey'] as String?,
365 isDefault: item['isDefault'] as bool? ?? false,
341 - createdAt: DateTime.parse(item['createdAt'] as String),
342 - lastConnectedAt: DateTime.parse(item['lastConnectedAt'] as String),
366 + createdAt: createdAt,
367 + lastConnectedAt: lastConnectedAt,
368 status: ConnectionStatus.values.byName(
369 item['status'] as String? ?? 'disconnected',
370 ),
@@ -359,11 +384,15 @@ class DataBackupService {
384 if (savedFolders is List) {
385 for (final item in savedFolders) {
386 if (item is! Map) continue;
387 + final id = _backupString(item['id']);
388 + final folderName = _backupString(item['name']);
389 + final createdAt = _backupDateTime(item['createdAt']);
390 + if (id == null || folderName == null || createdAt == null) continue;
391 final entity = SavedMessageFolderEntity(
363 - id: item['id'] as String,
364 - name: item['name'] as String,
392 + id: id,
393 + name: folderName,
394 sortOrder: item['sortOrder'] as int? ?? 0,
366 - createdAt: DateTime.parse(item['createdAt'] as String),
395 + createdAt: createdAt,
396 );
397 final query = savedFolderBox
398 .query(SavedMessageFolderEntity_.id.equals(entity.id))
@@ -379,16 +408,26 @@ class DataBackupService {
408 if (savedMessages is List) {
409 for (final item in savedMessages) {
410 if (item is! Map) continue;
411 + final id = _backupString(item['id']);
412 + final sourceMessageId = _backupString(item['sourceMessageId']);
413 + final conversationId = _backupString(item['conversationId']);
414 + final savedAt = _backupDateTime(item['savedAt']);
415 + if (id == null ||
416 + sourceMessageId == null ||
417 + conversationId == null ||
418 + savedAt == null) {
419 + continue;
420 + }
421 final entity = SavedMessageEntity(
383 - id: item['id'] as String,
384 - sourceMessageId: item['sourceMessageId'] as String,
385 - conversationId: item['conversationId'] as String,
422 + id: id,
423 + sourceMessageId: sourceMessageId,
424 + conversationId: conversationId,
425 conversationTitle: item['conversationTitle'] as String? ?? 'Chat',
426 roleIndex: item['roleIndex'] as int? ?? 0,
427 content: item['content'] as String? ?? '',
428 modelId: item['modelId'] as String?,
429 folderId: item['folderId'] as String?,
391 - savedAt: DateTime.parse(item['savedAt'] as String),
430 + savedAt: savedAt,
431 );
432 final query =
433 savedBox.query(SavedMessageEntity_.id.equals(entity.id)).build();
@@ -403,11 +442,15 @@ class DataBackupService {
442 if (conversations is List) {
443 for (final item in conversations) {
444 if (item is! Map) continue;
445 + final id = _backupString(item['id']);
446 + final createdAt = _backupDateTime(item['createdAt']);
447 + final updatedAt = _backupDateTime(item['updatedAt']);
448 + if (id == null || createdAt == null || updatedAt == null) continue;
449 final conversation = Conversation(
407 - id: item['id'] as String,
450 + id: id,
451 title: item['title'] as String? ?? 'Imported Chat',
409 - createdAt: DateTime.parse(item['createdAt'] as String),
410 - updatedAt: DateTime.parse(item['updatedAt'] as String),
452 + createdAt: createdAt,
453 + updatedAt: updatedAt,
454 isPinned: item['isPinned'] as bool? ?? false,
455 personaId: item['personaId'] as String?,
456 serverId: item['serverId'] as String?,
@@ -441,19 +484,25 @@ class DataBackupService {
484 if (messages is List) {
485 for (final item in messages) {
486 if (item is! Map) continue;
487 + final conversationId = _backupString(item['conversationId']);
488 + final id = _backupString(item['id']);
489 + final createdAt = _backupDateTime(item['createdAt']);
490 + if (conversationId == null || id == null || createdAt == null) {
491 + continue;
492 + }
493 final convQuery = convBox
445 - .query(ConversationEntity_.id.equals(item['conversationId'] as String))
494 + .query(ConversationEntity_.id.equals(conversationId))
495 .build();
496 final convEntity = convQuery.findFirst();
497 convQuery.close();
498 if (convEntity == null) continue;
499
500 final message = Message(
452 - id: item['id'] as String,
453 - conversationId: item['conversationId'] as String,
501 + id: id,
502 + conversationId: conversationId,
503 role: MessageRole.values.byName(item['role'] as String? ?? 'user'),
504 content: item['content'] as String? ?? '',
456 - createdAt: DateTime.parse(item['createdAt'] as String),
505 + createdAt: createdAt,
506 status: MessageStatus.values.byName(
507 item['status'] as String? ?? 'complete',
508 ),
@@ -496,3 +545,15 @@ class DataBackupService {
545 }
546 }
547 }
548 +
549 +String? _backupString(Object? value) => value is String ? value : null;
550 +
551 +DateTime? _backupDateTime(Object? value) {
552 + final raw = _backupString(value);
553 + if (raw == null) return null;
554 + try {
555 + return DateTime.parse(raw);
556 + } catch (_) {
557 + return null;
558 + }
559 +}
lib/features/chat/data/chat_service.dart
+4 -3
@@ -461,11 +461,12 @@ class LMStudioChatService implements ChatService {
461 for (final path in m.attachmentPaths!) {
462 if (!AttachmentHelpers.isImagePath(path)) continue;
463 try {
464 + final file = File(path);
465 + if (!await file.exists()) continue;
466 + final fileBytes = await file.readAsBytes();
467 final base64Image = await Isolate.run(() {
468 try {
466 - final file = File(path);
467 - if (!file.existsSync()) return null;
468 - return base64Encode(file.readAsBytesSync());
469 + return base64Encode(fileBytes);
470 } catch (_) {
471 return null;
472 }
lib/features/chat/providers/chat_notifier.dart
+47 -28
@@ -101,6 +101,7 @@ class ChatNotifier extends Notifier<ChatState> {
101 static const _checkpointTimeThreshold = Duration(seconds: 2);
102
103 StreamSubscription<ChatResponse>? _streamSubscription;
104 + int _streamGeneration = 0;
105 Timer? _uiUpdateTimer;
106 Message? _latestStreamingMessage;
107 String? _currentConversationId;
@@ -350,7 +351,12 @@ class ChatNotifier extends Notifier<ChatState> {
351 .setEnabled(settings.newChatMcpEnabled);
352 }
353
354 + void _invalidateStreamCallbacks() {
355 + _streamGeneration++;
356 + }
357 +
358 void _abortStreamImmediately() {
359 + _invalidateStreamCallbacks();
360 _uiUpdateTimer?.cancel();
361 _uiUpdateTimer = null;
362 _clearPendingApproval();
@@ -611,6 +617,7 @@ class ChatNotifier extends Notifier<ChatState> {
617
618 try {
619 _streamSubscription?.cancel();
620 + _invalidateStreamCallbacks();
621 _uiUpdateTimer?.cancel();
622
623 String reasoningContent = '';
@@ -649,6 +656,7 @@ class ChatNotifier extends Notifier<ChatState> {
656 updateUiState();
657 });
658
659 + final streamGeneration = _streamGeneration;
660 _streamSubscription = chatService
661 .sendMessage(
662 server: server,
@@ -660,6 +668,7 @@ class ChatNotifier extends Notifier<ChatState> {
668 )
669 .listen(
670 (response) async {
671 + if (streamGeneration != _streamGeneration) return;
672 final streamConvId = assistantMessage.conversationId;
673 final isCurrentContext = _currentConversationId == streamConvId;
674
@@ -1235,6 +1244,7 @@ class ChatNotifier extends Notifier<ChatState> {
1244 }
1245
1246 Future<void> cancelStream() async {
1247 + _invalidateStreamCallbacks();
1248 _uiUpdateTimer?.cancel();
1249 _uiUpdateTimer = null;
1250 _clearPendingApproval();
@@ -1272,37 +1282,43 @@ class ChatNotifier extends Notifier<ChatState> {
1282 final messages = state.messages;
1283 if (messages.isEmpty) return;
1284
1275 - if (messages.last.role == MessageRole.assistant) {
1276 - final lastAssistantIndex = messages.lastIndexWhere(
1277 - (m) => m.role == MessageRole.assistant,
1278 - );
1279 - if (lastAssistantIndex > 0) {
1280 - final userMessageIndex = lastAssistantIndex - 1;
1281 - final userMessage = messages[userMessageIndex];
1282 - final messagesToRemove = messages.sublist(userMessageIndex);
1283 - final db = ref.read(databaseProvider);
1284 -
1285 - for (final msg in messagesToRemove) {
1286 - final query = db.messageBox
1287 - .query(MessageEntity_.id.equals(msg.id))
1288 - .build();
1289 - db.messageBox.removeMany(query.findIds());
1290 - query.close();
1291 - }
1292 -
1293 - state = state.copyWith(
1294 - messages: messages.sublist(0, userMessageIndex),
1295 - clearStreaming: true,
1296 - );
1285 + final lastAssistantIndex = messages.lastIndexWhere(
1286 + (m) => m.role == MessageRole.assistant,
1287 + );
1288 + if (lastAssistantIndex < 0) return;
1289
1298 - await sendMessage(
1299 - userMessage.content,
1300 - attachments: userMessage.attachmentPaths
1301 - ?.map((p) => File(p))
1302 - .toList(),
1303 - );
1290 + Message? userMessage;
1291 + for (var i = lastAssistantIndex - 1; i >= 0; i--) {
1292 + if (messages[i].role == MessageRole.user) {
1293 + userMessage = messages[i];
1294 + break;
1295 }
1296 }
1297 + if (userMessage == null) return;
1298 +
1299 + final userMessageIndex = messages.indexWhere((m) => m.id == userMessage!.id);
1300 + final messagesToRemove = messages.sublist(userMessageIndex);
1301 + final db = ref.read(databaseProvider);
1302 +
1303 + for (final msg in messagesToRemove) {
1304 + final query = db.messageBox
1305 + .query(MessageEntity_.id.equals(msg.id))
1306 + .build();
1307 + db.messageBox.removeMany(query.findIds());
1308 + query.close();
1309 + }
1310 +
1311 + state = state.copyWith(
1312 + messages: messages.sublist(0, userMessageIndex),
1313 + clearStreaming: true,
1314 + );
1315 +
1316 + await sendMessage(
1317 + userMessage.content,
1318 + attachments: userMessage.attachmentPaths
1319 + ?.map((p) => File(p))
1320 + .toList(),
1321 + );
1322 }
1323
1324 Future<void> continueFromMessage(String messageId) async {
@@ -1472,6 +1488,7 @@ class ChatNotifier extends Notifier<ChatState> {
1488
1489 try {
1490 _streamSubscription?.cancel();
1491 + _invalidateStreamCallbacks();
1492 _uiUpdateTimer?.cancel();
1493
1494 String reasoningContent = '';
@@ -1504,6 +1521,7 @@ class ChatNotifier extends Notifier<ChatState> {
1521 updateUiState();
1522 });
1523
1524 + final streamGeneration = _streamGeneration;
1525 _streamSubscription = chatService
1526 .sendMessage(
1527 server: server,
@@ -1516,6 +1534,7 @@ class ChatNotifier extends Notifier<ChatState> {
1534 )
1535 .listen(
1536 (response) async {
1537 + if (streamGeneration != _streamGeneration) return;
1538 if (_activeConversationId != assistantMessage.conversationId) {
1539 return;
1540 }
lib/features/chat/views/chat_screen.dart
+8 -302
@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:go_router/go_router.dart';
8 import 'package:hugeicons/hugeicons.dart';
9 import 'package:shadcn_ui/shadcn_ui.dart';
10 -import 'package:localmind/core/theme/colors.dart';
10 import 'package:localmind/core/models/enums.dart';
11 import 'package:localmind/core/providers/app_providers.dart';
12 import 'package:localmind/core/routes/app_routes.dart';
@@ -42,6 +41,7 @@ import 'components/top_bar/model_top_bar.dart';
41 import 'components/top_bar/connection_banner.dart';
42 import 'components/top_bar/persona_indicator.dart';
43 import 'components/top_bar/smart_reply_chips.dart';
44 +import 'components/persona_picker_sheet.dart';
45
46 class ChatScreen extends ConsumerStatefulWidget {
47 const ChatScreen({super.key});
@@ -124,7 +124,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
124 case 'share_chat':
125 _shareConversation(context);
126 case 'persona':
127 - _showPersonaPicker(context);
127 + showPersonaPickerSheet(context);
128 case 'remove_persona':
129 final activeConv = ref.read(conv.activeConversationProvider);
130 if (activeConv != null) {
@@ -166,108 +166,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
166 }
167 }
168
169 - void _showPersonaPicker(BuildContext context) {
170 - final personasAsync = ref.read(personasNotifierProvider);
171 - final personas = personasAsync.value ?? [];
172 - final isDark = Theme.of(context).brightness == Brightness.dark;
173 - final activeConv = ref.read(conv.activeConversationProvider);
174 - final currentPersonaId = activeConv?.personaId;
175 -
176 - showModalBottomSheet(
177 - context: context,
178 - isScrollControlled: true,
179 - builder: (ctx) {
180 - final sheetL10n = AppLocalizations.of(context)!;
181 - return SafeArea(
182 - child: Container(
183 - padding: const EdgeInsets.symmetric(vertical: 16),
184 - child: Column(
185 - mainAxisSize: MainAxisSize.min,
186 - children: [
187 - Center(
188 - child: Container(
189 - width: 40,
190 - height: 4,
191 - decoration: BoxDecoration(
192 - color: isDark ? Colors.grey[600] : Colors.grey[300],
193 - borderRadius: BorderRadius.circular(2),
194 - ),
195 - ),
196 - ),
197 - const SizedBox(height: 16),
198 - Text(
199 - sheetL10n.select_persona,
200 - style: TextStyle(
201 - fontSize: 18,
202 - fontWeight: FontWeight.bold,
203 - color: isDark ? Colors.white : Colors.black,
204 - ),
205 - ),
206 - const SizedBox(height: 8),
207 - Flexible(
208 - child: ListView.builder(
209 - shrinkWrap: true,
210 - itemCount: personas.length,
211 - itemBuilder: (context, index) {
212 - final p = personas[index];
213 - final isSelected = p.id == currentPersonaId;
214 - final accent = isDark
215 - ? AppColors.darkAccent
216 - : AppColors.lightAccent;
217 - return ListTile(
218 - leading: Text(
219 - p.emoji,
220 - style: const TextStyle(fontSize: 22),
221 - ),
222 - title: Text(
223 - p.name,
224 - style: TextStyle(
225 - fontWeight: isSelected
226 - ? FontWeight.w600
227 - : FontWeight.normal,
228 - color: isDark ? Colors.white : Colors.black,
229 - ),
230 - ),
231 - subtitle: p.description != null
232 - ? Text(
233 - p.description!,
234 - style: TextStyle(
235 - fontSize: 12,
236 - color: isDark
237 - ? AppColors.darkMutedText
238 - : AppColors.lightMutedText,
239 - ),
240 - maxLines: 1,
241 - overflow: TextOverflow.ellipsis,
242 - )
243 - : null,
244 - trailing: isSelected
245 - ? Icon(Icons.check_circle, color: accent)
246 - : null,
247 - onTap: () {
248 - if (activeConv != null) {
249 - ref
250 - .read(conv.conversationsProvider.notifier)
251 - .updatePersona(
252 - activeConv.id,
253 - p.id,
254 - p.systemPrompt,
255 - );
256 - }
257 - Navigator.pop(ctx);
258 - },
259 - );
260 - },
261 - ),
262 - ),
263 - ],
264 - ),
265 - ),
266 - );
267 - },
268 - );
269 - }
270 -
169 Future<void> _shareConversation(BuildContext context) async {
170 final messages = ref.read(chatProvider).messages;
171 if (messages.isEmpty) return;
@@ -451,7 +349,7 @@ class _ChatBody extends ConsumerWidget {
349 isTemporary: isTemporary,
350 hasMessages: messages.isNotEmpty,
351 onMenuAction: onMenuAction,
454 - onPersonaPicker: () => _openPersonaPicker(context, ref),
352 + onPersonaPicker: () => showPersonaPickerSheet(context),
353 onChatModeAction: () => _handleChatModeAction(
354 context,
355 ref,
@@ -466,7 +364,7 @@ class _ChatBody extends ConsumerWidget {
364 if (persona != null)
365 PersonaIndicator(
366 persona: persona,
469 - onTap: () => _openPersonaPicker(context, ref),
367 + onTap: () => showPersonaPickerSheet(context),
368 onRemove: () {
369 final activeConv = ref.read(conv.activeConversationProvider);
370 if (activeConv != null) {
@@ -520,108 +418,6 @@ class _ChatBody extends ConsumerWidget {
418 }
419 }
420
523 -void _openPersonaPicker(BuildContext context, WidgetRef ref) {
524 - final personasAsync = ref.read(personasNotifierProvider);
525 - final personas = personasAsync.value ?? [];
526 - final isDark = Theme.of(context).brightness == Brightness.dark;
527 - final activeConv = ref.read(conv.activeConversationProvider);
528 - final currentPersonaId = activeConv?.personaId;
529 -
530 - showModalBottomSheet(
531 - context: context,
532 - isScrollControlled: true,
533 - builder: (ctx) {
534 - final sheetL10n = AppLocalizations.of(context)!;
535 - return SafeArea(
536 - child: Container(
537 - padding: const EdgeInsets.symmetric(vertical: 16),
538 - child: Column(
539 - mainAxisSize: MainAxisSize.min,
540 - children: [
541 - Center(
542 - child: Container(
543 - width: 40,
544 - height: 4,
545 - decoration: BoxDecoration(
546 - color: isDark ? Colors.grey[600] : Colors.grey[300],
547 - borderRadius: BorderRadius.circular(2),
548 - ),
549 - ),
550 - ),
551 - const SizedBox(height: 16),
552 - Text(
553 - sheetL10n.select_persona,
554 - style: TextStyle(
555 - fontSize: 18,
556 - fontWeight: FontWeight.bold,
557 - color: isDark ? Colors.white : Colors.black,
558 - ),
559 - ),
560 - const SizedBox(height: 8),
561 - Flexible(
562 - child: ListView.builder(
563 - shrinkWrap: true,
564 - itemCount: personas.length,
565 - itemBuilder: (context, index) {
566 - final p = personas[index];
567 - final isSelected = p.id == currentPersonaId;
568 - final accent = isDark
569 - ? AppColors.darkAccent
570 - : AppColors.lightAccent;
571 - return ListTile(
572 - leading: Text(
573 - p.emoji,
574 - style: const TextStyle(fontSize: 22),
575 - ),
576 - title: Text(
577 - p.name,
578 - style: TextStyle(
579 - fontWeight: isSelected
580 - ? FontWeight.w600
581 - : FontWeight.normal,
582 - color: isDark ? Colors.white : Colors.black,
583 - ),
584 - ),
585 - subtitle: p.description != null
586 - ? Text(
587 - p.description!,
588 - style: TextStyle(
589 - fontSize: 12,
590 - color: isDark
591 - ? AppColors.darkMutedText
592 - : AppColors.lightMutedText,
593 - ),
594 - maxLines: 1,
595 - overflow: TextOverflow.ellipsis,
596 - )
597 - : null,
598 - trailing: isSelected
599 - ? Icon(Icons.check_circle, color: accent)
600 - : null,
601 - onTap: () {
602 - if (activeConv != null) {
603 - ref
604 - .read(conv.conversationsProvider.notifier)
605 - .updatePersona(
606 - activeConv.id,
607 - p.id,
608 - p.systemPrompt,
609 - );
610 - }
611 - Navigator.pop(ctx);
612 - },
613 - );
614 - },
615 - ),
616 - ),
617 - ],
618 - ),
619 - ),
620 - );
621 - },
622 - ).then((_) {});
623 -}
624 -
421 class _MessageArea extends ConsumerWidget {
422 const _MessageArea({
423 required this.isLoading,
@@ -679,7 +475,10 @@ class _MessageArea extends ConsumerWidget {
475 selectedModel: selectedModel,
476 onModelTap: onModelPicker,
477 selectedPersona: ref.watch(selectedPersonaProvider),
682 - onPersonaTap: () => _openPersonaPickerForPreselection(context, ref),
478 + onPersonaTap: () => showPersonaPickerSheet(
479 + context,
480 + mode: PersonaPickerMode.preselection,
481 + ),
482 );
483 }
484
@@ -749,99 +548,6 @@ class _MessageArea extends ConsumerWidget {
548 }
549 }
550
752 -void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
753 - final personasAsync = ref.read(personasNotifierProvider);
754 - final personas = personasAsync.value ?? [];
755 - final isDark = Theme.of(context).brightness == Brightness.dark;
756 - final currentPersona = ref.watch(selectedPersonaProvider);
757 -
758 - showModalBottomSheet(
759 - context: context,
760 - isScrollControlled: true,
761 - builder: (ctx) {
762 - final sheetL10n = AppLocalizations.of(context)!;
763 - return SafeArea(
764 - child: Container(
765 - padding: const EdgeInsets.symmetric(vertical: 16),
766 - child: Column(
767 - mainAxisSize: MainAxisSize.min,
768 - children: [
769 - Center(
770 - child: Container(
771 - width: 40,
772 - height: 4,
773 - decoration: BoxDecoration(
774 - color: isDark ? Colors.grey[600] : Colors.grey[300],
775 - borderRadius: BorderRadius.circular(2),
776 - ),
777 - ),
778 - ),
779 - const SizedBox(height: 16),
780 - Text(
781 - sheetL10n.select_persona,
782 - style: TextStyle(
783 - fontSize: 18,
784 - fontWeight: FontWeight.bold,
785 - color: isDark ? Colors.white : Colors.black,
786 - ),
787 - ),
788 - const SizedBox(height: 8),
789 - Flexible(
790 - child: ListView.builder(
791 - shrinkWrap: true,
792 - itemCount: personas.length,
793 - itemBuilder: (context, index) {
794 - final p = personas[index];
795 - final isSelected = p.id == currentPersona?.id;
796 - final accent = isDark
797 - ? AppColors.darkAccent
798 - : AppColors.lightAccent;
799 - return ListTile(
800 - leading: Text(
801 - p.emoji,
802 - style: const TextStyle(fontSize: 22),
803 - ),
804 - title: Text(
805 - p.name,
806 - style: TextStyle(
807 - fontWeight: isSelected
808 - ? FontWeight.w600
809 - : FontWeight.normal,
810 - color: isDark ? Colors.white : Colors.black,
811 - ),
812 - ),
813 - subtitle: p.description != null
814 - ? Text(
815 - p.description!,
816 - style: TextStyle(
817 - fontSize: 12,
818 - color: isDark
819 - ? AppColors.darkMutedText
820 - : AppColors.lightMutedText,
821 - ),
822 - maxLines: 1,
823 - overflow: TextOverflow.ellipsis,
824 - )
825 - : null,
826 - trailing: isSelected
827 - ? Icon(Icons.check_circle, color: accent)
828 - : null,
829 - onTap: () {
830 - ref.read(selectedPersonaProvider.notifier).select(p);
831 - Navigator.pop(ctx);
832 - },
833 - );
834 - },
835 - ),
836 - ),
837 - ],
838 - ),
839 - ),
840 - );
841 - },
842 - ).then((_) {});
843 -}
844 -
551 void _handleChatModeAction(
552 BuildContext context,
553 WidgetRef ref, {
lib/features/conversations/providers/conversation_providers.dart
+14 -4
@@ -650,7 +650,15 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
650
651 Future<List<ConversationFolder>> _loadAll() async {
652 final db = ref.read(databaseProvider);
653 - final entities = db.conversationFolderBox.getAll();
653 + return db.store.runInTransactionAsync(
654 + TxMode.read,
655 + _loadFoldersInBackground,
656 + null,
657 + );
658 + }
659 +
660 + static List<ConversationFolder> _loadFoldersInBackground(Store store, _) {
661 + final entities = store.box<ConversationFolderEntity>().getAll();
662 final folders = entities
663 .map(
664 (e) => ConversationFolder(
@@ -753,7 +761,7 @@ class FocusHistorySearchNotifier extends Notifier<int> {
761
762 void requestFocus() => state++;
763
756 - void clear() {}
764 + void clear() => state = 0;
765 }
766
767 class ScrollToMessageNotifier extends Notifier<String?> {
@@ -771,14 +779,16 @@ final messageSearchResultsProvider = Provider<List<MessageSearchHit>>((ref) {
779 return const [];
780 }
781
774 - final db = ref.read(databaseProvider);
782 + final db = ref.watch(databaseProvider);
783 final conversations = db.conversationBox.getAll();
784 final titleById = {
785 for (final conv in conversations) conv.id: conv.title,
786 };
787
788 final hits = <MessageSearchHit>[];
781 - final messageQuery = db.messageBox.query().build();
789 + final messageQuery = db.messageBox
790 + .query(MessageEntity_.content.contains(query, caseSensitive: false))
791 + .build();
792 final messages = messageQuery.find();
793 messageQuery.close();
794
lib/features/saved_messages/providers/saved_message_providers.dart
+7 -2
@@ -1,5 +1,6 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 +import '../../../core/models/enums.dart';
4 import '../../../core/providers/storage_providers.dart';
5 import '../../../core/storage/entities.dart';
6 import '../../../objectbox.g.dart';
@@ -251,9 +252,13 @@ final filteredSavedMessagesProvider =
252 .where((m) => m.conversationId.isEmpty)
253 .toList();
254 case SavedMessageListFilter.user:
254 - filtered = filtered.where((m) => m.roleIndex == 0).toList();
255 + filtered = filtered
256 + .where((m) => m.roleIndex == MessageRole.user.index)
257 + .toList();
258 case SavedMessageListFilter.assistant:
256 - filtered = filtered.where((m) => m.roleIndex == 1).toList();
259 + filtered = filtered
260 + .where((m) => m.roleIndex == MessageRole.assistant.index)
261 + .toList();
262 case SavedMessageListFilter.all:
263 break;
264 }
lib/features/servers/data/server_api_service.dart
+2 -1
@@ -76,7 +76,6 @@ class ServerApiService {
76
77 switch (server.type) {
78 case ServerType.lmStudio:
79 - case ServerType.openAICompatible:
79 return _parseRunningOpenAICompatibleModels(response.data);
80 case ServerType.ollama:
81 return _parseRunningOllamaModels(response.data);
@@ -84,6 +83,8 @@ class ServerApiService {
83 return {};
84 case ServerType.onDevice:
85 return {};
86 + case ServerType.openAICompatible:
87 + return {};
88 }
89 } catch (e) {
90 return {};
lib/features/settings/views/data_backup_actions.dart
+2 -2
@@ -234,8 +234,8 @@ class DataBackupActions extends ConsumerWidget {
234 Widget build(BuildContext context, WidgetRef ref) {
235 final l10n = AppLocalizations.of(context)!;
236 final service = DataBackupService();
237 - final db = ref.read(databaseProvider);
238 - final prefs = ref.read(sharedPreferencesProvider);
237 + final db = ref.watch(databaseProvider);
238 + final prefs = ref.watch(sharedPreferencesProvider);
239
240 return Column(
241 crossAxisAlignment: CrossAxisAlignment.stretch,
lib/features/tts/utils/tts_text_processor.dart
+2 -2
@@ -51,8 +51,8 @@ class TtsTextProcessor {
51 RegExp(r'\$(\d+(?:\.\d+)?)'),
52 (m) => '${m.group(1)} dollars',
53 );
54 - // Lone `$` symbols
55 - s = s.replaceAll(r'$', ' dollars ');
54 + // Lone `$` symbols (not followed by a word character, e.g. `$HOME`)
55 + s = s.replaceAll(RegExp(r'\$(?!\w)'), ' dollars ');
56 // Collapse whitespace
57 s = s.replaceAll(RegExp(r'[ \t]+'), ' ');
58 s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n');
lib/features/tts/utils/wav_merge.dart
+57 -28
@@ -6,41 +6,70 @@ Uint8List mergeWavFiles(List<Uint8List> wavFiles) {
6 if (wavFiles.length == 1) return wavFiles.first;
7
8 final first = wavFiles.first;
9 - if (first.length < 44) return first;
9 + final dataOffset = _findPcmDataOffset(first);
10 + if (dataOffset == null) return first;
11 +
12 + final dataSizeOffset = _findDataChunkSizeOffset(first);
13
14 final pcmBuilder = BytesBuilder(copy: false);
15 for (final wav in wavFiles) {
13 - if (wav.length > 44) {
14 - pcmBuilder.add(wav.sublist(44));
16 + final offset = _findPcmDataOffset(wav);
17 + if (offset != null && wav.length > offset) {
18 + pcmBuilder.add(wav.sublist(offset));
19 }
20 }
21 final pcmData = pcmBuilder.toBytes();
18 - final totalSize = 36 + pcmData.length;
19 -
20 - final merged = ByteData(44);
21 - merged.setUint8(0, 0x52); // R
22 - merged.setUint8(1, 0x49); // I
23 - merged.setUint8(2, 0x46); // F
24 - merged.setUint8(3, 0x46); // F
25 - merged.setUint32(4, totalSize, Endian.little);
26 - merged.setUint8(8, 0x57); // W
27 - merged.setUint8(9, 0x41); // A
28 - merged.setUint8(10, 0x56); // V
29 - merged.setUint8(11, 0x45); // E
30 -
31 - // Copy fmt chunk from first file (bytes 12-35)
32 - for (var i = 12; i < 36; i++) {
33 - merged.setUint8(i, first[i]);
22 +
23 + final header = Uint8List.fromList(first.sublist(0, dataOffset));
24 + _writeUint32LE(header, 4, header.length + pcmData.length - 8);
25 + if (dataSizeOffset != null) {
26 + _writeUint32LE(header, dataSizeOffset, pcmData.length);
27 }
28
36 - merged.setUint8(36, 0x64); // d
37 - merged.setUint8(37, 0x61); // a
38 - merged.setUint8(38, 0x74); // t
39 - merged.setUint8(39, 0x61); // a
40 - merged.setUint32(40, pcmData.length, Endian.little);
29 + return Uint8List.fromList([...header, ...pcmData]);
30 +}
31 +
32 +int? _findPcmDataOffset(Uint8List wav) {
33 + final dataSizeOffset = _findDataChunkSizeOffset(wav);
34 + return dataSizeOffset == null ? null : dataSizeOffset + 4;
35 +}
36 +
37 +int? _findDataChunkSizeOffset(Uint8List wav) {
38 + if (wav.length < 12) return null;
39 + if (wav[0] != 0x52 ||
40 + wav[1] != 0x49 ||
41 + wav[2] != 0x46 ||
42 + wav[3] != 0x46 ||
43 + wav[8] != 0x57 ||
44 + wav[9] != 0x41 ||
45 + wav[10] != 0x56 ||
46 + wav[11] != 0x45) {
47 + return null;
48 + }
49 +
50 + var offset = 12;
51 + while (offset + 8 <= wav.length) {
52 + final chunkId = String.fromCharCodes(wav.sublist(offset, offset + 4));
53 + final chunkSize = _readUint32LE(wav, offset + 4);
54 + if (chunkId == 'data') {
55 + return offset + 4;
56 + }
57 + offset += 8 + chunkSize;
58 + if (chunkSize.isOdd) offset++;
59 + }
60 + return null;
61 +}
62 +
63 +int _readUint32LE(Uint8List bytes, int offset) {
64 + return bytes[offset] |
65 + (bytes[offset + 1] << 8) |
66 + (bytes[offset + 2] << 16) |
67 + (bytes[offset + 3] << 24);
68 +}
69
42 - return Uint8List.fromList([
43 - ...merged.buffer.asUint8List(),
44 - ...pcmData,
45 - ]);
70 +void _writeUint32LE(Uint8List bytes, int offset, int value) {
71 + bytes[offset] = value & 0xff;
72 + bytes[offset + 1] = (value >> 8) & 0xff;
73 + bytes[offset + 2] = (value >> 16) & 0xff;
74 + bytes[offset + 3] = (value >> 24) & 0xff;
75 }