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
}