3 files changed
+260
-18
lib/features/chat/providers/chat_providers.dart
+121
-10
@@ -2,6 +2,8 @@ import 'dart:async';
2
import 'dart:io';
3
import 'dart:math';
4
5
+import '../../../core/logger/app_logger.dart';
6
+
7
import 'package:flutter_riverpod/flutter_riverpod.dart';
8
import '../../../core/providers/app_providers.dart';
9
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
@@ -304,8 +306,15 @@ final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
306
});
307
308
class ChatNotifier extends Notifier<ChatState> {
309
+ static const _checkpointChunkThreshold = 20;
310
+ static const _checkpointTimeThreshold = Duration(seconds: 2);
311
+
312
StreamSubscription<ChatResponse>? _streamSubscription;
313
String? _currentConversationId;
314
+ int _chunkCount = 0;
315
+ DateTime? _lastCheckpointTime;
316
+ int _lastSavedContentLength = 0;
317
+ int _lastSavedReasoningLength = 0;
318
319
@override
320
ChatState build() {
@@ -326,20 +335,58 @@ class ChatNotifier extends Notifier<ChatState> {
335
336
try {
337
final db = ref.read(databaseProvider);
329
- final query = db.messageBox
330
- .query(MessageEntity_.conversationUid.equals(conversation.id))
338
+
339
+ // Attempt to find the conversation entity to use its native relation
340
+ final convQuery = db.conversationBox
341
+ .query(ConversationEntity_.id.equals(conversation.id))
342
.build();
332
- final entities = query.find();
333
- query.close();
343
+ final convEntity = convQuery.findFirst();
344
+ convQuery.close();
345
+
346
+ List<Message> messages = [];
347
+
348
+ if (convEntity != null) {
349
+ // 1. Try native ToMany relation first (managed by ObjectBox internal IDs)
350
+ final relatedEntities = convEntity.messages;
351
+ if (relatedEntities.isNotEmpty) {
352
+ messages = relatedEntities.map((e) => e.toDomain()).toList();
353
+ } else {
354
+ // 2. Fallback/Repair: Search by the indexed conversationUid string
355
+ final query = db.messageBox
356
+ .query(MessageEntity_.conversationUid.equals(conversation.id))
357
+ .build();
358
+ final manualEntities = query.find();
359
+ query.close();
360
+
361
+ if (manualEntities.isNotEmpty) {
362
+ // Automatic Repair: Link orphaned messages to the native relation
363
+ // for future consistency and performance.
364
+ for (final e in manualEntities) {
365
+ e.conversation.target = convEntity;
366
+ db.messageBox.put(e);
367
+ }
368
+ messages = manualEntities.map((e) => e.toDomain()).toList();
369
+ }
370
+ }
371
+ } else {
372
+ // Fallback for cases where the conversation entity itself might be hard to find by ID
373
+ // but messages exist tied to that UID string.
374
+ final query = db.messageBox
375
+ .query(MessageEntity_.conversationUid.equals(conversation.id))
376
+ .build();
377
+ final entities = query.find();
378
+ query.close();
379
+ messages = entities.map((e) => e.toDomain()).toList();
380
+ }
381
335
- final messages = entities.map((e) => e.toDomain()).toList();
382
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
383
384
state = ChatState(messages: messages, isLoading: false);
385
ref
386
.read(conv.activeConversationProvider.notifier)
387
.setActiveConversation(conversation);
342
- } catch (e) {
388
+ } catch (e, stackTrace) {
389
+ Log.fatal(error: e, stackTrace: stackTrace);
390
state = state.copyWith(
391
isLoading: false,
392
errorMessage: 'Failed to load conversation: $e',
@@ -349,7 +396,8 @@ class ChatNotifier extends Notifier<ChatState> {
396
397
Future<void> startNewConversation() async {
398
await cancelStream();
352
- await clearConversation();
399
+ _currentConversationId = null;
400
+ state = const ChatState();
401
ref
402
.read(conv.activeConversationProvider.notifier)
403
.setActiveConversation(null);
@@ -371,6 +419,36 @@ class ChatNotifier extends Notifier<ChatState> {
419
.join('-');
420
}
421
422
+ bool _shouldCheckpointSave(Message message) {
423
+ final now = DateTime.now();
424
+ final contentLength = message.content.length;
425
+ final reasoningLength = message.reasoningContent?.length ?? 0;
426
+
427
+ final hasNewContent =
428
+ contentLength > _lastSavedContentLength ||
429
+ reasoningLength > _lastSavedReasoningLength;
430
+
431
+ if (!hasNewContent) return false;
432
+
433
+ final meetsChunkThreshold = _chunkCount >= _checkpointChunkThreshold;
434
+
435
+ final meetsTimeThreshold =
436
+ _lastCheckpointTime != null &&
437
+ now.difference(_lastCheckpointTime!) >= _checkpointTimeThreshold;
438
+
439
+ return meetsChunkThreshold || meetsTimeThreshold;
440
+ }
441
+
442
+ void _resetCheckpointMetrics() {
443
+ _chunkCount = 0;
444
+ _lastCheckpointTime = DateTime.now();
445
+ }
446
+
447
+ void _updateSavedMetrics(Message message) {
448
+ _lastSavedContentLength = message.content.length;
449
+ _lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
450
+ }
451
+
452
Future<void> sendMessage(String content, {List<File>? attachments}) async {
453
final server = ref.read(activeServerProvider);
454
final selectedModel = ref.read(selectedModelProvider);
@@ -456,6 +534,9 @@ class ChatNotifier extends Notifier<ChatState> {
534
await _saveMessage(userMessage);
535
await _saveMessage(assistantMessage);
536
537
+ _resetCheckpointMetrics();
538
+ _updateSavedMetrics(assistantMessage);
539
+
540
final messagesForApi = _buildMessagesForApi(selectedModel);
541
542
try {
@@ -487,6 +568,14 @@ class ChatNotifier extends Notifier<ChatState> {
568
isProcessing: false,
569
);
570
571
+ _chunkCount++;
572
+
573
+ if (_shouldCheckpointSave(streamingAssistantMessage)) {
574
+ await _saveMessage(streamingAssistantMessage);
575
+ _updateSavedMetrics(streamingAssistantMessage);
576
+ _resetCheckpointMetrics();
577
+ }
578
+
579
if (isCurrentContext) {
580
final messageIndex = state.messages.indexWhere(
581
(m) => m.id == assistantMessageId,
@@ -516,6 +605,14 @@ class ChatNotifier extends Notifier<ChatState> {
605
isProcessing: false,
606
);
607
608
+ _chunkCount++;
609
+
610
+ if (_shouldCheckpointSave(streamingAssistantMessage)) {
611
+ await _saveMessage(streamingAssistantMessage);
612
+ _updateSavedMetrics(streamingAssistantMessage);
613
+ _resetCheckpointMetrics();
614
+ }
615
+
616
if (isCurrentContext) {
617
state = state.copyWith(
618
streamingMessage: streamingAssistantMessage,
@@ -678,10 +775,19 @@ class ChatNotifier extends Notifier<ChatState> {
775
);
776
}
777
}
778
+
779
+ _chunkCount = 0;
780
+ _lastCheckpointTime = null;
781
+ _lastSavedContentLength = 0;
782
+ _lastSavedReasoningLength = 0;
783
}
784
}
785
},
786
onError: (error) async {
787
+ _chunkCount = 0;
788
+ _lastCheckpointTime = null;
789
+ _lastSavedContentLength = 0;
790
+ _lastSavedReasoningLength = 0;
791
final errorMessage = state.streamingMessage?.copyWith(
792
status: MessageStatus.error,
793
errorMessage: error.toString(),
@@ -838,6 +944,11 @@ class ChatNotifier extends Notifier<ChatState> {
944
_streamSubscription = null;
945
ref.read(chatServiceProvider)?.cancelStream();
946
947
+ _chunkCount = 0;
948
+ _lastCheckpointTime = null;
949
+ _lastSavedContentLength = 0;
950
+ _lastSavedReasoningLength = 0;
951
+
952
final streamingMessage = state.streamingMessage;
953
if (streamingMessage != null) {
954
final finalMessage = streamingMessage.copyWith(
@@ -948,7 +1059,7 @@ class ChatNotifier extends Notifier<ChatState> {
1059
entity.internalId = existing.internalId;
1060
}
1061
951
- // Set relation
1062
+ // Set native ObjectBox relation
1063
final convQuery = db.conversationBox
1064
.query(ConversationEntity_.id.equals(message.conversationId))
1065
.build();
@@ -957,10 +1068,10 @@ class ChatNotifier extends Notifier<ChatState> {
1068
1069
if (convEntity != null) {
1070
entity.conversation.target = convEntity;
960
- // Also ensure the string UID is set correctly
1071
+ // Also ensure the string UID fallback is set correctly
1072
entity.conversationUid = convEntity.id;
1073
} else {
963
- // Fallback: search by UID if the entity was just created but not found by ID (unlikely but safe)
1074
+ // Fallback: search by UID if the entity was just created in this frame (unlikely but safe)
1075
entity.conversationUid = message.conversationId;
1076
}
1077
lib/features/chat/views/chat_screen.dart
+138
-7
@@ -1,4 +1,5 @@
1
import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:go_router/go_router.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -216,6 +217,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
217
const Center(child: CircularProgressIndicator(strokeWidth: 2))
218
else if (chatState.messages.isEmpty && activeConversation != null)
219
_CorruptedChatState(
220
+ conversation: activeConversation,
221
+ errorMessage: chatState.errorMessage,
222
onStartNewChat: () =>
223
ref.read(chatProvider.notifier).startNewConversation(),
224
)
@@ -1368,13 +1371,104 @@ class _PersonaIndicator extends StatelessWidget {
1371
}
1372
}
1373
1371
-class _CorruptedChatState extends StatelessWidget {
1372
- const _CorruptedChatState({required this.onStartNewChat});
1374
+class _CorruptedChatState extends ConsumerWidget {
1375
+ const _CorruptedChatState({
1376
+ required this.conversation,
1377
+ this.errorMessage,
1378
+ required this.onStartNewChat,
1379
+ });
1380
1381
+ final Conversation conversation;
1382
+ final String? errorMessage;
1383
final VoidCallback onStartNewChat;
1384
1385
+ void _showDebugInfo(BuildContext context, WidgetRef ref) {
1386
+ showShadDialog(
1387
+ context: context,
1388
+ builder: (context) => ShadDialog(
1389
+ title: const Text('Technical Details'),
1390
+ description: const Text(
1391
+ 'Diagnostic information to help identify synchronization issues.',
1392
+ ),
1393
+ actions: [
1394
+ ShadButton.outline(
1395
+ onPressed: () {
1396
+ final data =
1397
+ '''
1398
+ID: ${conversation.id}
1399
+Title: ${conversation.title}
1400
+Expected: ${conversation.messageCount}
1401
+Error: $errorMessage
1402
+''';
1403
+ Clipboard.setData(ClipboardData(text: data));
1404
+ ScaffoldMessenger.of(context).showSnackBar(
1405
+ const SnackBar(content: Text('Copied to clipboard')),
1406
+ );
1407
+ },
1408
+ child: const Text('Copy Info'),
1409
+ ),
1410
+ ShadButton(
1411
+ onPressed: () => Navigator.of(context).pop(),
1412
+ child: const Text('Close'),
1413
+ ),
1414
+ ],
1415
+ child: Container(
1416
+ width: 400,
1417
+ padding: const EdgeInsets.symmetric(vertical: 20),
1418
+ child: Column(
1419
+ crossAxisAlignment: CrossAxisAlignment.start,
1420
+ mainAxisSize: MainAxisSize.min,
1421
+ children: [
1422
+ _DebugRow(label: 'Conversation ID', value: conversation.id),
1423
+ _DebugRow(
1424
+ label: 'Created At',
1425
+ value: conversation.createdAt.toIso8601String(),
1426
+ ),
1427
+ _DebugRow(
1428
+ label: 'Expected Messages',
1429
+ value: '${conversation.messageCount}',
1430
+ ),
1431
+ if (errorMessage != null)
1432
+ Padding(
1433
+ padding: const EdgeInsets.only(top: 12),
1434
+ child: Column(
1435
+ crossAxisAlignment: CrossAxisAlignment.start,
1436
+ children: [
1437
+ const Text(
1438
+ 'Last Error:',
1439
+ style: TextStyle(
1440
+ fontSize: 12,
1441
+ fontWeight: FontWeight.bold,
1442
+ color: Colors.red,
1443
+ ),
1444
+ ),
1445
+ const SizedBox(height: 4),
1446
+ Container(
1447
+ padding: const EdgeInsets.all(8),
1448
+ decoration: BoxDecoration(
1449
+ color: Colors.red.withValues(alpha: 0.1),
1450
+ borderRadius: BorderRadius.circular(4),
1451
+ ),
1452
+ child: Text(
1453
+ errorMessage!,
1454
+ style: const TextStyle(
1455
+ fontSize: 11,
1456
+ fontFamily: 'monospace',
1457
+ ),
1458
+ ),
1459
+ ),
1460
+ ],
1461
+ ),
1462
+ ),
1463
+ ],
1464
+ ),
1465
+ ),
1466
+ ),
1467
+ );
1468
+ }
1469
+
1470
@override
1377
- Widget build(BuildContext context) {
1471
+ Widget build(BuildContext context, WidgetRef ref) {
1472
final theme = Theme.of(context);
1473
final isDark = theme.brightness == Brightness.dark;
1474
@@ -1415,10 +1509,20 @@ class _CorruptedChatState extends StatelessWidget {
1509
),
1510
),
1511
const SizedBox(height: 32),
1418
- ShadButton(
1419
- onPressed: onStartNewChat,
1420
- leading: const Icon(Icons.add_rounded, size: 20),
1421
- child: const Text('Start New Chat'),
1512
+ Row(
1513
+ mainAxisAlignment: MainAxisAlignment.center,
1514
+ children: [
1515
+ ShadButton(
1516
+ onPressed: onStartNewChat,
1517
+ leading: const Icon(Icons.add_rounded, size: 20),
1518
+ child: const Text('Start New Chat'),
1519
+ ),
1520
+ const SizedBox(width: 12),
1521
+ ShadButton.outline(
1522
+ onPressed: () => _showDebugInfo(context, ref),
1523
+ child: const Text('Technical Details'),
1524
+ ),
1525
+ ],
1526
),
1527
],
1528
),
@@ -1426,3 +1530,30 @@ class _CorruptedChatState extends StatelessWidget {
1530
);
1531
}
1532
}
1533
+
1534
+class _DebugRow extends StatelessWidget {
1535
+ const _DebugRow({required this.label, required this.value});
1536
+ final String label;
1537
+ final String value;
1538
+
1539
+ @override
1540
+ Widget build(BuildContext context) {
1541
+ return Padding(
1542
+ padding: const EdgeInsets.only(bottom: 8),
1543
+ child: Column(
1544
+ crossAxisAlignment: CrossAxisAlignment.start,
1545
+ children: [
1546
+ Text(
1547
+ label,
1548
+ style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
1549
+ ),
1550
+ const SizedBox(height: 2),
1551
+ SelectableText(
1552
+ value,
1553
+ style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
1554
+ ),
1555
+ ],
1556
+ ),
1557
+ );
1558
+ }
1559
+}
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.0.0+7
19
+version: 1.0.0+8
20
21
environment:
22
sdk: ^3.11.0