@tduncan / Cortana / commits / cd9d5dc

feat: implement incremental message checkpointing and improve conversation loading reliability with automatic relation repair

abdulmominsakib committed Apr 8, 2026 at 17:59 UTC cd9d5dc173be3059b5a3982f5972d67b08bc241a
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