@tduncan / Cortana / commits / d822406

refactor: improve UUID generation and encapsulate chat stream processing in try-catch blocks

abdulmominsakib committed Jul 1, 2026 at 20:20 UTC d822406f1571bf97610f43ac441d6446e6075f8a
11 files changed +222 -137
lib/core/providers/app_providers.dart
+7
@@ -108,6 +108,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
108 Future<void> _update(AppSettings updated) async {
109 state = updated;
110 final prefs = ref.read(sharedPreferencesProvider);
111 + // Fire-and-forget persistence: in-memory state is updated synchronously
112 + // above so the UI reflects the new value immediately. The async write to
113 + // SharedPreferences is not awaited by setter callers (which are sync
114 + // UI handlers), so a hard app kill between the state mutation and the
115 + // write completing can lose the most recent setting change. Settings
116 + // are flushed on every state change and on app pause via
117 + // [flushPendingSettings] in the root widget.
118 await prefs.setString('appSettings', updated.toJson());
119 }
120
lib/core/storage/entities.dart
+68 -12
@@ -70,14 +70,18 @@ class ServerEntity {
70 return Server(
71 id: id,
72 name: name,
73 - type: ServerType.values[typeIndex],
73 + type: typeIndex >= 0 && typeIndex < ServerType.values.length
74 + ? ServerType.values[typeIndex]
75 + : ServerType.values.first,
76 host: host,
77 port: port,
78 apiKey: apiKey,
79 isDefault: isDefault,
80 createdAt: createdAt,
81 lastConnectedAt: lastConnectedAt,
80 - status: ConnectionStatus.values[statusIndex],
82 + status: statusIndex >= 0 && statusIndex < ConnectionStatus.values.length
83 + ? ConnectionStatus.values[statusIndex]
84 + : ConnectionStatus.values.first,
85 iconName: iconName,
86 );
87 }
@@ -148,7 +152,17 @@ class PersonaEntity {
152 updatedAt: updatedAt,
153 category: category,
154 preferredParams: preferredParamsJson != null
151 - ? Map<String, dynamic>.from(jsonDecode(preferredParamsJson!))
155 + ? () {
156 + try {
157 + final decoded = jsonDecode(preferredParamsJson!);
158 + if (decoded is Map) {
159 + return Map<String, dynamic>.from(decoded);
160 + }
161 + return null;
162 + } catch (_) {
163 + return null;
164 + }
165 + }()
166 : null,
167 );
168 }
@@ -253,7 +267,17 @@ class ConversationEntity {
267 contextLength: contextLength,
268 mcpEnabled: mcpEnabled,
269 smartReplies: smartRepliesJson != null
256 - ? List<String>.from(jsonDecode(smartRepliesJson!))
270 + ? () {
271 + try {
272 + final decoded = jsonDecode(smartRepliesJson!);
273 + if (decoded is List) {
274 + return List<String>.from(decoded);
275 + }
276 + return null;
277 + } catch (_) {
278 + return null;
279 + }
280 + }()
281 : null,
282 smartRepliesLastMessageId: smartRepliesLastMessageId,
283 );
@@ -353,27 +377,59 @@ class MessageEntity {
377 role: MessageRole.values[roleIndex],
378 content: content,
379 createdAt: createdAt,
356 - status: MessageStatus.values[statusIndex],
380 + status: statusIndex >= 0 && statusIndex < MessageStatus.values.length
381 + ? MessageStatus.values[statusIndex]
382 + : MessageStatus.values.first,
383 modelId: modelId,
384 tokenCount: tokenCount,
385 errorMessage: errorMessage,
386 attachmentPaths: attachmentPathsJson != null
361 - ? List<String>.from(jsonDecode(attachmentPathsJson!))
387 + ? () {
388 + try {
389 + final decoded = jsonDecode(attachmentPathsJson!);
390 + if (decoded is List) {
391 + return List<String>.from(decoded);
392 + }
393 + return null;
394 + } catch (_) {
395 + return null;
396 + }
397 + }()
398 : null,
399 generationTimeMs: generationTimeMs,
400 reasoningContent: reasoningContent,
401 toolCalls: toolCallsJson != null
366 - ? (jsonDecode(toolCallsJson!) as List)
367 - .map((e) => ToolCallData.fromMap(Map<String, dynamic>.from(e)))
368 - .toList()
402 + ? () {
403 + try {
404 + final decoded = jsonDecode(toolCallsJson!);
405 + if (decoded is List) {
406 + return decoded
407 + .map((e) => ToolCallData.fromMap(Map<String, dynamic>.from(e as Map)))
408 + .toList();
409 + }
410 + return null;
411 + } catch (_) {
412 + return null;
413 + }
414 + }()
415 : null,
416 toolCallId: toolCallId,
417 isProcessing: isProcessing,
418 toolSessionId: toolSessionId,
419 toolEvents: toolEventsJson != null
374 - ? (jsonDecode(toolEventsJson!) as List)
375 - .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)))
376 - .toList()
420 + ? () {
421 + try {
422 + final decoded = jsonDecode(toolEventsJson!);
423 + if (decoded is List) {
424 + return decoded
425 + .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e as Map)))
426 + .toList();
427 + }
428 + return null;
429 + } catch (_) {
430 + return null;
431 + }
432 + }()
433 : null,
434 );
435 }
lib/features/chat/data/chat_service.dart
+118 -110
@@ -528,122 +528,125 @@ class OpenAICompatibleChatService implements ChatService {
528
529 Log.debug('OpenAICompatible: Starting to receive stream...');
530
531 - await for (final chunk in stream) {
532 - // Check if timeout was triggered
533 - if (timeoutTriggered && lastContentReceived == null) {
534 - _timeoutTimer?.cancel();
535 - yield const ChatResponse(
536 - type: ChatResponseType.timeoutError,
537 - content:
538 - 'Model is taking too long to respond. This may happen with free tier models.',
539 - );
540 - return;
541 - }
542 -
543 - buffer += chunk;
544 - final lines = buffer.split('\n');
545 - buffer = lines.removeLast();
546 -
547 - for (final line in lines) {
548 - logCounter++;
549 - if (logCounter % 10 == 0) {
550 - Log.debug('OpenAICompatible SSE line #$logCounter');
531 + try {
532 + await for (final chunk in stream) {
533 + // Check if timeout was triggered
534 + if (timeoutTriggered && lastContentReceived == null) {
535 + yield const ChatResponse(
536 + type: ChatResponseType.timeoutError,
537 + content:
538 + 'Model is taking too long to respond. This may happen with free tier models.',
539 + );
540 + return;
541 }
542
553 - if (line.startsWith('data: ')) {
554 - final data = line.substring(6);
555 - if (data == '[DONE]') {
556 - _timeoutTimer?.cancel();
557 - Log.debug('OpenAICompatible: Received [DONE]');
558 - for (final call in toolAdapter.takeCompletedCalls()) {
559 - yield ChatResponse(
560 - type: ChatResponseType.toolCall,
561 - toolCall: ToolCallData(
562 - tool: call.name,
563 - arguments: call.arguments,
564 - ),
565 - );
566 - }
567 - yield const ChatResponse(type: ChatResponseType.done);
568 - return;
543 + buffer += chunk;
544 + final lines = buffer.split('\n');
545 + buffer = lines.removeLast();
546 +
547 + for (final line in lines) {
548 + logCounter++;
549 + if (logCounter % 10 == 0) {
550 + Log.debug('OpenAICompatible SSE line #$logCounter');
551 }
570 - try {
571 - final json = jsonDecode(data) as Map<String, dynamic>;
572 - toolAdapter.consumeDynamicChunk(json);
552
574 - // Check for errors in the response
575 - if (json['error'] != null) {
553 + if (line.startsWith('data: ')) {
554 + final data = line.substring(6);
555 + if (data == '[DONE]') {
556 _timeoutTimer?.cancel();
577 - final error = json['error'];
578 - String errorMsg;
579 - if (error is Map) {
580 - errorMsg = (error['message'] ?? 'Unknown API error') as String;
581 - } else {
582 - errorMsg = error.toString();
557 + Log.debug('OpenAICompatible: Received [DONE]');
558 + for (final call in toolAdapter.takeCompletedCalls()) {
559 + yield ChatResponse(
560 + type: ChatResponseType.toolCall,
561 + toolCall: ToolCallData(
562 + tool: call.name,
563 + arguments: call.arguments,
564 + ),
565 + );
566 }
584 - Log.error('OpenAICompatible mid-stream error: $errorMsg');
585 - yield ChatResponse(
586 - type: ChatResponseType.error,
587 - content: 'API Error: $errorMsg',
588 - );
567 + yield const ChatResponse(type: ChatResponseType.done);
568 return;
569 }
591 -
592 - final choices = json['choices'] as List<dynamic>?;
593 - if (choices == null || choices.isEmpty) {
594 - continue;
595 - }
596 -
597 - final firstChoice = choices[0] as Map<String, dynamic>?;
598 - if (firstChoice == null) continue;
599 -
600 - final delta = firstChoice['delta'];
601 - if (delta == null || delta is! Map<String, dynamic>) continue;
602 -
603 - final content = (delta['content'] ?? delta['text']) as String?;
604 - final reasoning =
605 - (delta['reasoning'] ?? delta['reasoning_content']) as String?;
606 - final refusal = delta['refusal'] as String?;
607 -
608 - // Check if content is empty/null (processing but not outputting)
609 - final hasContent = content != null && content.isNotEmpty;
610 - final hasReasoning = reasoning != null && reasoning.isNotEmpty;
611 - final hasRefusal = refusal != null && refusal.isNotEmpty;
612 -
613 - if (!hasContent && !hasReasoning && !hasRefusal) {
614 - emptyDeltaCount++;
615 - if (emptyDeltaCount % 10 == 0) {
616 - yield const ChatResponse(type: ChatResponseType.processing);
617 - }
618 - } else {
619 - emptyDeltaCount = 0;
620 - lastContentReceived = DateTime.now();
621 -
622 - if (hasRefusal) {
570 + try {
571 + final json = jsonDecode(data) as Map<String, dynamic>;
572 + toolAdapter.consumeDynamicChunk(json);
573 +
574 + // Check for errors in the response
575 + if (json['error'] != null) {
576 + _timeoutTimer?.cancel();
577 + final error = json['error'];
578 + String errorMsg;
579 + if (error is Map) {
580 + errorMsg = (error['message'] ?? 'Unknown API error') as String;
581 + } else {
582 + errorMsg = error.toString();
583 + }
584 + Log.error('OpenAICompatible mid-stream error: $errorMsg');
585 yield ChatResponse(
586 type: ChatResponseType.error,
625 - content: 'Refusal: $refusal',
587 + content: 'API Error: $errorMsg',
588 );
589 return;
590 }
629 - if (hasContent) {
630 - yield ChatResponse(
631 - type: ChatResponseType.message,
632 - content: content,
633 - );
591 +
592 + final choices = json['choices'] as List<dynamic>?;
593 + if (choices == null || choices.isEmpty) {
594 + continue;
595 }
635 - if (hasReasoning) {
636 - yield ChatResponse(
637 - type: ChatResponseType.reasoning,
638 - reasoningContent: reasoning,
639 - );
596 +
597 + final firstChoice = choices[0] as Map<String, dynamic>?;
598 + if (firstChoice == null) continue;
599 +
600 + final delta = firstChoice['delta'];
601 + if (delta == null || delta is! Map<String, dynamic>) continue;
602 +
603 + final content = (delta['content'] ?? delta['text']) as String?;
604 + final reasoning =
605 + (delta['reasoning'] ?? delta['reasoning_content']) as String?;
606 + final refusal = delta['refusal'] as String?;
607 +
608 + // Check if content is empty/null (processing but not outputting)
609 + final hasContent = content != null && content.isNotEmpty;
610 + final hasReasoning = reasoning != null && reasoning.isNotEmpty;
611 + final hasRefusal = refusal != null && refusal.isNotEmpty;
612 +
613 + if (!hasContent && !hasReasoning && !hasRefusal) {
614 + emptyDeltaCount++;
615 + if (emptyDeltaCount % 10 == 0) {
616 + yield const ChatResponse(type: ChatResponseType.processing);
617 + }
618 + } else {
619 + emptyDeltaCount = 0;
620 + lastContentReceived = DateTime.now();
621 +
622 + if (hasRefusal) {
623 + yield ChatResponse(
624 + type: ChatResponseType.error,
625 + content: 'Refusal: $refusal',
626 + );
627 + return;
628 + }
629 + if (hasContent) {
630 + yield ChatResponse(
631 + type: ChatResponseType.message,
632 + content: content,
633 + );
634 + }
635 + if (hasReasoning) {
636 + yield ChatResponse(
637 + type: ChatResponseType.reasoning,
638 + reasoningContent: reasoning,
639 + );
640 + }
641 }
642 + } catch (e) {
643 + Log.error('OpenAICompatible parsing error: $e');
644 }
642 - } catch (e) {
643 - Log.error('OpenAICompatible parsing error: $e');
645 }
646 }
647 }
648 + } finally {
649 + _timeoutTimer?.cancel();
650 }
651
652 // Flush any tool calls accumulated without a [DONE] marker (e.g.
@@ -664,9 +667,10 @@ class OpenAICompatibleChatService implements ChatService {
667 content: _handleChatError(e),
668 );
669 return;
670 + } finally {
671 + _timeoutTimer?.cancel();
672 }
673
669 - _timeoutTimer?.cancel();
674 yield const ChatResponse(type: ChatResponseType.done);
675 Log.debug('OpenAICompatible: Stream ended');
676 }
@@ -870,17 +874,17 @@ class OpenRouterChatService implements ChatService {
874
875 Log.debug('OpenRouter: Starting to receive stream...');
876
873 - await for (final chunk in stream) {
874 - // Check if timeout was triggered
875 - if (timeoutTriggered && lastContentReceived == null) {
876 - _timeoutTimer?.cancel();
877 - yield const ChatResponse(
878 - type: ChatResponseType.timeoutError,
879 - content:
880 - 'Model is taking too long to respond. This may happen with free tier models.',
881 - );
882 - return;
883 - }
877 + try {
878 + await for (final chunk in stream) {
879 + // Check if timeout was triggered
880 + if (timeoutTriggered && lastContentReceived == null) {
881 + yield const ChatResponse(
882 + type: ChatResponseType.timeoutError,
883 + content:
884 + 'Model is taking too long to respond. This may happen with free tier models.',
885 + );
886 + return;
887 + }
888
889 buffer += chunk;
890 final lines = buffer.split('\n');
@@ -992,6 +996,9 @@ class OpenRouterChatService implements ChatService {
996 }
997 }
998 }
999 + } finally {
1000 + _timeoutTimer?.cancel();
1001 + }
1002
1003 // Flush any tool calls accumulated without a [DONE] marker (e.g.
1004 // connection drop or server crash before sending [DONE]).
@@ -1011,9 +1018,10 @@ class OpenRouterChatService implements ChatService {
1018 content: _handleChatError(e),
1019 );
1020 return;
1021 + } finally {
1022 + _timeoutTimer?.cancel();
1023 }
1024
1016 - _timeoutTimer?.cancel();
1025 yield const ChatResponse(type: ChatResponseType.done);
1026 Log.debug('OpenRouter: Stream ended');
1027 }
lib/features/chat/data/models/message.dart
+4 -4
@@ -107,10 +107,10 @@ class ToolCallData {
107
108 factory ToolCallData.fromMap(Map<String, dynamic> map) {
109 return ToolCallData(
110 - id: map['id'],
111 - toolName: map['toolName'],
112 - arguments: Map<String, dynamic>.from(map['arguments'] ?? {}),
113 - result: map['result'],
110 + id: (map['id'] as String?) ?? '',
111 + toolName: (map['toolName'] as String?) ?? '',
112 + arguments: Map<String, dynamic>.from((map['arguments'] as Map?) ?? {}),
113 + result: map['result'] as String?,
114 );
115 }
116
lib/features/chat/data/smart_reply_service.dart
+1
@@ -90,6 +90,7 @@ class SmartReplyService {
90 const Duration(seconds: 15),
91 onTimeout: () {
92 subscription?.cancel();
93 + chatService.cancelStream();
94 return accumulatedContent;
95 },
96 );
lib/features/chat/providers/chat_notifier.dart
+1 -1
@@ -146,7 +146,7 @@ class ChatNotifier extends Notifier<ChatState> {
146 final db = ref.read(databaseProvider);
147
148 final messages = await db.store.runInTransactionAsync(
149 - TxMode.write,
149 + TxMode.read,
150 _loadMessagesInBackground,
151 conversation.id,
152 );
lib/features/chat/views/components/chat_screen_content.dart
+9 -6
@@ -10,6 +10,7 @@ import 'package:localmind/core/components/model_picker/model_picker_sheet.dart';
10 import 'package:localmind/core/components/server/server_icon_picker.dart';
11 import 'package:localmind/core/components/tts/tts_player_bar.dart';
12 import 'package:localmind/core/models/enums.dart';
13 +import 'package:localmind/core/models/model_info.dart';
14 import 'package:localmind/core/providers/app_providers.dart';
15 import 'package:localmind/core/providers/conversation_providers.dart' as conv;
16 import 'package:localmind/core/providers/personas_providers.dart';
@@ -17,6 +18,8 @@ import 'package:localmind/core/providers/server_providers.dart';
18 import 'package:localmind/core/routes/app_routes.dart';
19 import 'package:localmind/core/utils/system_insets.dart';
20 import 'package:localmind/l10n/app_localizations.dart';
21 +import 'package:localmind/features/conversations/data/models/conversation.dart';
22 +import 'package:localmind/features/personas/data/models/persona.dart';
23 import 'package:localmind/features/servers/data/models/server.dart';
24 import '../../data/models/message.dart';
25 import '../../providers/chat_mcp_providers.dart';
@@ -532,9 +535,9 @@ class _MessageArea extends ConsumerWidget {
535
536 final bool isLoading;
537 final List<Message> messages;
535 - final dynamic activeConversation;
538 + final Conversation? activeConversation;
539 final String? errorMessage;
537 - final dynamic selectedModel;
540 + final ModelInfo? selectedModel;
541 final bool isStreaming;
542 final ScrollController scrollController;
543 final double effectiveBottomInset;
@@ -551,7 +554,7 @@ class _MessageArea extends ConsumerWidget {
554
555 if (messages.isEmpty && activeConversation != null) {
556 return CorruptedChatState(
554 - conversation: activeConversation,
557 + conversation: activeConversation!,
558 errorMessage: errorMessage,
559 onStartNewChat: () =>
560 ref.read(chatProvider.notifier).startNewConversation(),
@@ -708,10 +711,10 @@ class _ScreenAppBar extends ConsumerWidget {
711 required this.onPersonaPicker,
712 });
713
711 - final dynamic activeServer;
714 + final Server? activeServer;
715 final ConnectionStatus connectionStatus;
716 final bool isDark;
714 - final dynamic persona;
717 + final Persona? persona;
718 final void Function(String) onMenuAction;
719 final VoidCallback onPersonaPicker;
720
@@ -743,7 +746,7 @@ class _ScreenAppBar extends ConsumerWidget {
746 child: Row(
747 children: [
748 if (activeServer != null) ...[
746 - _buildServerIcon(context, activeServer as Server?),
749 + _buildServerIcon(context, activeServer),
750 const SizedBox(width: 8),
751 ],
752 Text(
lib/features/chat/views/components/message_list/corrupted_state.dart
+2 -1
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:shadcn_ui/shadcn_ui.dart';
5 +import 'package:localmind/features/conversations/data/models/conversation.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'debug_row.dart';
8
@@ -13,7 +14,7 @@ class CorruptedChatState extends ConsumerWidget {
14 required this.onStartNewChat,
15 });
16
16 - final dynamic conversation;
17 + final Conversation conversation;
18 final String? errorMessage;
19 final VoidCallback onStartNewChat;
20
lib/features/conversations/providers/conversation_providers.dart
+7 -2
@@ -1,3 +1,5 @@
1 +import 'dart:math';
2 +
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5 import '../../../core/providers/storage_providers.dart';
@@ -325,8 +327,11 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
327 }
328
329 String _generateUuid() {
328 - final random = DateTime.now().microsecondsSinceEpoch;
329 - return '${random.toRadixString(16)}-${DateTime.now().millisecondsSinceEpoch.toRadixString(16)}';
330 + final secure = Random.secure();
331 + final now = DateTime.now().microsecondsSinceEpoch.toRadixString(16);
332 + final r1 = secure.nextInt(0xFFFFFFFF).toRadixString(16).padLeft(8, '0');
333 + final r2 = secure.nextInt(0xFFFFFFFF).toRadixString(16).padLeft(8, '0');
334 + return '$now-$r1-$r2';
335 }
336 }
337
lib/features/on_device/data/on_device_chat_service.dart
+3
@@ -20,6 +20,9 @@ class OnDeviceChatService implements ChatService {
20 gemma.InferenceChat? _activeChat;
21 String? _activeModelId;
22 String? _activeSystemInstruction;
23 + // Safe in Dart's single-threaded event loop: reads/writes are not
24 + // interrupted by other isolates, and async boundaries only yield control
25 + // at `await` points — so simple `bool` state is sufficient.
26 bool _isCancelled = false;
27
28 OnDeviceChatService(this._gemmaService);
lib/features/servers/data/repositories/server_api_service.dart
+2 -1
@@ -75,12 +75,13 @@ class ServerApiService {
75
76 switch (server.type) {
77 case ServerType.lmStudio:
78 - case ServerType.openAICompatible:
78 return _parseRunningOpenAICompatibleModels(response.data);
79 case ServerType.ollama:
80 return _parseRunningOllamaModels(response.data);
81 case ServerType.openRouter:
82 return {};
83 + case ServerType.openAICompatible:
84 + return {};
85 case ServerType.onDevice:
86 return {};
87 }