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
}