@tduncan / Cortana / commits / 5a0feef

fix: critical start app bug fix on some devices with 16 KB devices

abdulmominsakib committed Aug 9, 2026 at 16:17 UTC 5a0feefe5ace2ddc910aa54bc258e697ee0381be
18 files changed +1222 -543
android/app/src/main/AndroidManifest.xml
+7
@@ -3,6 +3,7 @@
3 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
6 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
7 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8 <uses-permission android:name="android.permission.WAKE_LOCK" />
9 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -58,6 +59,12 @@
59 android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
60 android:value="Used to keep the local AI model inference process alive when the user switches apps, preventing the OS from terminating the heavy background computation." />
61 </service>
62 +
63 + <service
64 + android:name="androidx.work.impl.foreground.SystemForegroundService"
65 + android:foregroundServiceType="dataSync"
66 + tools:node="merge"
67 + tools:replace="android:foregroundServiceType" />
68 <service
69 android:name="com.ryanheise.audioservice.AudioService"
70 android:foregroundServiceType="mediaPlayback"
lib/features/chat/data/smart_reply_service.dart
+60 -23
@@ -9,6 +9,8 @@ import 'models/chat_parameters.dart';
9 import 'models/message.dart';
10
11 class SmartReplyService {
12 + final Map<String, Future<List<String>>> _inFlightSuggestions = {};
13 +
14 Future<List<String>> suggestRepliesWithLLM({
15 required ChatService chatService,
16 required Server server,
@@ -19,6 +21,40 @@ class SmartReplyService {
21 }) async {
22 if (messages.isEmpty) return [];
23
24 + final lastMessage = messages.last;
25 + final requestKey =
26 + '${lastMessage.conversationId}:${lastMessage.id}:$modelId';
27 + final existing = _inFlightSuggestions[requestKey];
28 + if (existing != null) return existing;
29 +
30 + final request = _suggestRepliesWithLLM(
31 + chatService: chatService,
32 + server: server,
33 + modelId: modelId,
34 + messages: messages,
35 + params: params,
36 + personaSystemPrompt: personaSystemPrompt,
37 + );
38 + _inFlightSuggestions[requestKey] = request;
39 + try {
40 + return await request;
41 + } finally {
42 + if (identical(_inFlightSuggestions[requestKey], request)) {
43 + _inFlightSuggestions.remove(requestKey);
44 + }
45 + }
46 + }
47 +
48 + Future<List<String>> _suggestRepliesWithLLM({
49 + required ChatService chatService,
50 + required Server server,
51 + required String modelId,
52 + required List<Message> messages,
53 + required ChatParameters params,
54 + String? personaSystemPrompt,
55 + }) async {
56 + if (messages.isEmpty) return [];
57 +
58 final lastMessage = messages.last;
59
60 // Construct the prompt message for generating suggestions
@@ -26,16 +62,14 @@ class SmartReplyService {
62 id: 'smart-reply-prompt',
63 conversationId: lastMessage.conversationId,
64 role: MessageRole.user,
29 - content: 'Based on the conversation history above, suggest 3 short, natural, context-appropriate reply options that the user (human) might want to send next. '
65 + content:
66 + 'Based on the conversation history above, suggest 3 short, natural, context-appropriate reply options that the user (human) might want to send next. '
67 'Return them strictly as a JSON array of strings, for example: ["Reply 1", "Reply 2", "Reply 3"]. '
68 'Each reply should be brief (under 8 words). Return ONLY the raw JSON array, with no markdown code block formatting (do not wrap in ```json), explanations, or extra text.',
69 createdAt: DateTime.now(),
70 );
71
35 - final apiMessages = [
36 - ...messages,
37 - promptMessage,
38 - ];
72 + final apiMessages = [...messages, promptMessage];
73
74 const baseInstruction =
75 'You are a smart reply assistant. Your job is to output exactly a JSON array containing 3 suggested short replies for the user. Do not output anything other than the JSON array.';
@@ -44,7 +78,8 @@ class SmartReplyService {
78 final suggestionParams = params.copyWith(
79 temperature: 0.2,
80 maxTokens: 128,
47 - systemPrompt: personaSystemPrompt != null && personaSystemPrompt.trim().isNotEmpty
81 + systemPrompt:
82 + personaSystemPrompt != null && personaSystemPrompt.trim().isNotEmpty
83 ? '$personaSystemPrompt\n\n$baseInstruction'
84 : baseInstruction,
85 );
@@ -63,7 +98,8 @@ class SmartReplyService {
98 StreamSubscription? subscription;
99 subscription = stream.listen(
100 (response) {
66 - if (response.type == ChatResponseType.message && response.content != null) {
101 + if (response.type == ChatResponseType.message &&
102 + response.content != null) {
103 accumulatedContent += response.content!;
104 } else if (response.type == ChatResponseType.done) {
105 subscription?.cancel();
@@ -73,7 +109,9 @@ class SmartReplyService {
109 } else if (response.type == ChatResponseType.error) {
110 subscription?.cancel();
111 if (!completer.isCompleted) {
76 - completer.completeError(response.content ?? 'Unknown streaming error');
112 + completer.completeError(
113 + response.content ?? 'Unknown streaming error',
114 + );
115 }
116 }
117 },
@@ -96,7 +134,6 @@ class SmartReplyService {
134 const Duration(seconds: 15),
135 onTimeout: () {
136 subscription?.cancel();
99 - chatService.cancelStream();
137 return accumulatedContent;
138 },
139 );
@@ -290,11 +327,7 @@ class SmartReplyService {
327 lowerContent.contains('goodbye') ||
328 lowerContent.contains('bye') ||
329 lowerContent.contains('see you')) {
293 - return [
294 - 'You\'re welcome!',
295 - 'Thanks for your help!',
296 - 'Have a great day!',
297 - ];
330 + return ['You\'re welcome!', 'Thanks for your help!', 'Have a great day!'];
331 }
332
333 // 3. Code & Programming
@@ -337,7 +370,10 @@ class SmartReplyService {
370
371 // 5. Lists & Steps
372 final hasNumberedList = RegExp(r'\b\d+\.\s').hasMatch(cleanContent);
340 - final hasBulletList = RegExp(r'^[\s]*[-*+]\s', multiLine: true).hasMatch(cleanContent);
373 + final hasBulletList = RegExp(
374 + r'^[\s]*[-*+]\s',
375 + multiLine: true,
376 + ).hasMatch(cleanContent);
377 if (hasNumberedList ||
378 hasBulletList ||
379 lowerContent.contains('step 1') ||
@@ -355,18 +391,15 @@ class SmartReplyService {
391
392 // 6. Questions asked by the assistant
393 if (cleanContent.endsWith('?')) {
358 - final isYesNoQuestion = lowerContent.contains('do you') ||
394 + final isYesNoQuestion =
395 + lowerContent.contains('do you') ||
396 lowerContent.contains('can you') ||
397 lowerContent.contains('is it') ||
398 lowerContent.contains('are you') ||
399 lowerContent.contains('would you') ||
400 lowerContent.contains('should we');
401 if (isYesNoQuestion) {
365 - return [
366 - 'Yes, please.',
367 - 'No, thank you.',
368 - 'Can you explain more?',
369 - ];
402 + return ['Yes, please.', 'No, thank you.', 'Can you explain more?'];
403 }
404 return [
405 'Sure, tell me more.',
@@ -441,7 +474,11 @@ class SmartReplyService {
474 ];
475 }
476
444 - void reset() {}
477 + void reset() {
478 + _inFlightSuggestions.clear();
479 + }
480
446 - void dispose() {}
481 + void dispose() {
482 + _inFlightSuggestions.clear();
483 + }
484 }
lib/features/chat/providers/chat_notifier.dart
+185 -148
@@ -122,6 +122,8 @@ class ChatNotifier extends Notifier<ChatState> {
122 int _lastSavedReasoningLength = 0;
123
124 MessageSaveService? _saveService;
125 + PendingToolApproval? _pendingToolApproval;
126 + final Set<String> _titleGenerationsInFlight = <String>{};
127 bool _pendingTemporaryChat = false;
128 String? _ephemeralConversationId;
129 bool _attemptedResume = false;
@@ -208,7 +210,8 @@ class ChatNotifier extends Notifier<ChatState> {
210 .firstOrNull;
211 final total = lastWithStats == null
212 ? 0
211 - : (lastWithStats.inputTokenCount ?? 0) + (lastWithStats.tokenCount ?? 0);
213 + : (lastWithStats.inputTokenCount ?? 0) +
214 + (lastWithStats.tokenCount ?? 0);
215 unawaited(
216 ref
217 .read(conv.conversationsProvider.notifier)
@@ -261,8 +264,9 @@ class ChatNotifier extends Notifier<ChatState> {
264 _recomputeConversationTotal(conversationId, messages: timeline);
265
266 if (isCurrentContext) {
264 - final lastUserMessage =
265 - timeline.where((m) => m.role == MessageRole.user).firstOrNull;
267 + final lastUserMessage = timeline
268 + .where((m) => m.role == MessageRole.user)
269 + .firstOrNull;
270 if (lastUserMessage != null) {
271 _maybeAutoGenerateTitleAfterFirstReply();
272 }
@@ -270,14 +274,15 @@ class ChatNotifier extends Notifier<ChatState> {
274 }
275
276 void approveTool(bool approved) {
273 - final pending = state.pendingToolApproval;
277 + final pending = _pendingToolApproval;
278 if (pending != null && !pending.completer.isCompleted) {
279 pending.completer.complete(approved);
280 }
281 }
282
283 void _clearPendingApproval() {
280 - final pending = state.pendingToolApproval;
284 + final pending = _pendingToolApproval;
285 + _pendingToolApproval = null;
286 if (pending != null) {
287 if (!pending.completer.isCompleted) {
288 pending.completer.complete(false);
@@ -297,7 +302,8 @@ class ChatNotifier extends Notifier<ChatState> {
302 _streamSubscription = null;
303 subscription?.cancel();
304 _saveService?.dispose();
300 - final pending = state.pendingToolApproval;
305 + final pending = _pendingToolApproval;
306 + _pendingToolApproval = null;
307 if (pending != null && !pending.completer.isCompleted) {
308 pending.completer.complete(false);
309 }
@@ -368,7 +374,7 @@ class ChatNotifier extends Notifier<ChatState> {
374 isTemporary: conversation.isTemporary,
375 );
376 ref
371 - .read(conv.activeConversationProvider.notifier)
377 + .read(conv.activeConversationIdProvider.notifier)
378 .setActiveConversation(conversation);
379
380 if (!conversation.isTemporary) {
@@ -431,20 +437,16 @@ class ChatNotifier extends Notifier<ChatState> {
437 }
438
439 messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
434 - final normalized = messages
435 - .asMap()
436 - .entries
437 - .map((entry) {
438 - final msg = entry.value;
439 - if (msg.variantGroupId?.isNotEmpty == true) return msg;
440 - return msg.copyWith(
441 - variantGroupId: msg.id,
442 - variantIndex: 0,
443 - threadOrder: entry.key,
444 - isActiveVariant: true,
445 - );
446 - })
447 - .toList();
440 + final normalized = messages.asMap().entries.map((entry) {
441 + final msg = entry.value;
442 + if (msg.variantGroupId?.isNotEmpty == true) return msg;
443 + return msg.copyWith(
444 + variantGroupId: msg.id,
445 + variantIndex: 0,
446 + threadOrder: entry.key,
447 + isActiveVariant: true,
448 + );
449 + }).toList();
450 return _assignLegacyParentsIfNeeded(normalized);
451 }
452
@@ -489,7 +491,7 @@ class ChatNotifier extends Notifier<ChatState> {
491 ref.read(smartReplyServiceProvider).reset();
492 state = const ChatState();
493 ref
492 - .read(conv.activeConversationProvider.notifier)
494 + .read(conv.activeConversationIdProvider.notifier)
495 .setActiveConversation(null);
496 ref.read(chatOriginProvider.notifier).clear();
497 ref.read(messageSelectionModeProvider.notifier).disable();
@@ -555,13 +557,15 @@ class ChatNotifier extends Notifier<ChatState> {
557 messages: MessageVariants.resolveActiveTimeline(updatedAll),
558 );
559
558 - unawaited(_persistCancelledMessageInBackground(
559 - streamingMessage,
560 - convId,
561 - stats: stats,
562 - startTime: startTime,
563 - firstTokenTime: firstTokenTime,
564 - ));
560 + unawaited(
561 + _persistCancelledMessageInBackground(
562 + streamingMessage,
563 + convId,
564 + stats: stats,
565 + startTime: startTime,
566 + firstTokenTime: firstTokenTime,
567 + ),
568 + );
569 }
570 }
571
@@ -710,7 +714,7 @@ class ChatNotifier extends Notifier<ChatState> {
714 );
715 _currentConversationId = conversation.id;
716 ref
713 - .read(conv.activeConversationProvider.notifier)
717 + .read(conv.activeConversationIdProvider.notifier)
718 .setActiveConversation(conversation);
719 _persistLastActiveConversation(conversation.id);
720 // A conversationsProvider refresh triggered later in this same send
@@ -761,8 +765,8 @@ class ChatNotifier extends Notifier<ChatState> {
765 final titleSource = trimmedContent.isNotEmpty
766 ? trimmedContent
767 : (attachments?.isNotEmpty == true
764 - ? attachments!.first.path.split(Platform.pathSeparator).last
765 - : 'New Chat');
768 + ? attachments!.first.path.split(Platform.pathSeparator).last
769 + : 'New Chat');
770 await _ensureConversationExists(titleSource);
771
772 // Read after the conversation (and its persona system prompt) is
@@ -784,8 +788,10 @@ class ChatNotifier extends Notifier<ChatState> {
788 }
789
790 for (final file in attachments) {
787 - final savedPath =
788 - await AttachmentHelpers.saveAttachment(file, attachmentsDir);
791 + final savedPath = await AttachmentHelpers.saveAttachment(
792 + file,
793 + attachmentsDir,
794 + );
795 if (savedPath != null) savedPaths.add(savedPath);
796 }
797 }
@@ -794,7 +800,9 @@ class ChatNotifier extends Notifier<ChatState> {
800 final userGroupId = generateUuid();
801 final assistantThreadOrder = userThreadOrder + 1;
802 final assistantGroupId = generateUuid();
797 - final lastInTimeline = state.messages.isNotEmpty ? state.messages.last : null;
803 + final lastInTimeline = state.messages.isNotEmpty
804 + ? state.messages.last
805 + : null;
806
807 final userMessage = Message(
808 id: generateUuid(),
@@ -843,11 +851,13 @@ class ChatNotifier extends Notifier<ChatState> {
851 if (!_isInMemoryChat && _currentConversationId != null) {
852 final preview = trimmedContent.isNotEmpty
853 ? (trimmedContent.length > 100
846 - ? '${trimmedContent.substring(0, 100)}...'
847 - : trimmedContent)
854 + ? '${trimmedContent.substring(0, 100)}...'
855 + : trimmedContent)
856 : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
857 final timeline = [...state.messages, userMessage];
850 - await ref.read(conv.conversationsProvider.notifier).syncConversationStats(
858 + await ref
859 + .read(conv.conversationsProvider.notifier)
860 + .syncConversationStats(
861 _currentConversationId!,
862 messageCount: timeline.length,
863 characterCount: timeline.fold<int>(
@@ -875,10 +885,10 @@ class ChatNotifier extends Notifier<ChatState> {
885
886 if (chatService != null) {
887 final mcpConfig = ref.read(chatMcpConfigProvider);
878 - final activeIntegrations =
879 - mcpConfig.integrations.where((i) => i.enabled).toList();
880 - final integrations =
881 - mcpConfig.enabled && activeIntegrations.isNotEmpty
888 + final activeIntegrations = mcpConfig.integrations
889 + .where((i) => i.enabled)
890 + .toList();
891 + final integrations = mcpConfig.enabled && activeIntegrations.isNotEmpty
892 ? activeIntegrations
893 : null;
894
@@ -973,7 +983,8 @@ class ChatNotifier extends Notifier<ChatState> {
983 stateNeedsUpdate = true;
984 break;
985 case ChatResponseType.processing:
976 - final wasProcessing = streamingAssistantMessage.isProcessing;
986 + final wasProcessing =
987 + streamingAssistantMessage.isProcessing;
988 streamingAssistantMessage = streamingAssistantMessage
989 .copyWith(isProcessing: true);
990 _latestStreamingMessage = streamingAssistantMessage;
@@ -1034,32 +1045,31 @@ class ChatNotifier extends Notifier<ChatState> {
1045 }
1046 },
1047 onDone: () async {
1037 - _uiUpdateTimer?.cancel();
1038 - _uiUpdateTimer = null;
1039 - updateUiState();
1048 + _uiUpdateTimer?.cancel();
1049 + _uiUpdateTimer = null;
1050 + updateUiState();
1051 +
1052 + if (streamHadError) {
1053 + // The error branch already finalised the message and
1054 + // stopped streaming — do not overwrite the error state
1055 + // with success/default text.
1056 + if (!_isInMemoryChat) {
1057 + await _saveService?.flush();
1058 + }
1059 + ref.read(chatBackgroundServiceProvider).stop();
1060 + return;
1061 + }
1062
1041 - if (streamHadError) {
1042 - // The error branch already finalised the message and
1043 - // stopped streaming — do not overwrite the error state
1044 - // with success/default text.
1063 if (!_isInMemoryChat) {
1064 await _saveService?.flush();
1065 }
1066 ref.read(chatBackgroundServiceProvider).stop();
1049 - return;
1050 - }
1051 -
1052 - if (!_isInMemoryChat) {
1053 - await _saveService?.flush();
1054 - }
1055 - ref.read(chatBackgroundServiceProvider).stop();
1056 - final streamConvId = assistantMessage.conversationId;
1057 - final isCurrentContext = _activeConversationId == streamConvId;
1067 + final streamConvId = assistantMessage.conversationId;
1068 + final isCurrentContext = _activeConversationId == streamConvId;
1069 final streamingMessage = streamingAssistantMessage;
1070 final hasContent =
1071 streamingMessage.content.isNotEmpty ||
1061 - (streamingMessage.reasoningContent?.isNotEmpty ??
1062 - false) ||
1072 + (streamingMessage.reasoningContent?.isNotEmpty ?? false) ||
1073 collectedToolCalls.isNotEmpty;
1074
1075 if (!hasContent) {
@@ -1071,10 +1081,7 @@ class ChatNotifier extends Notifier<ChatState> {
1081 );
1082 await _saveMessage(errorMessage);
1083 if (isCurrentContext) {
1074 - _replaceMessageInState(
1075 - errorMessage,
1076 - clearStreaming: true,
1077 - );
1084 + _replaceMessageInState(errorMessage, clearStreaming: true);
1085 state = state.copyWith(
1086 isStreaming: false,
1087 errorMessage: errorMessage.errorMessage,
@@ -1106,8 +1113,9 @@ class ChatNotifier extends Notifier<ChatState> {
1113 dedupedCalls[tc.tool] = tc;
1114 }
1115 final allCalls = dedupedCalls.values.toList();
1109 - final serverExecuted =
1110 - allCalls.where((tc) => tc.output != null).toList();
1116 + final serverExecuted = allCalls
1117 + .where((tc) => tc.output != null)
1118 + .toList();
1119 final clientExecuted = allCalls
1120 .where((tc) => tc.output == null)
1121 .toList();
@@ -1115,27 +1123,28 @@ class ChatNotifier extends Notifier<ChatState> {
1123 final toolEvents = <ToolEvent>[];
1124
1125 if (serverExecuted.isNotEmpty) {
1118 - final sessionId =
1119 - DateTime.now().millisecondsSinceEpoch.toString();
1126 + final sessionId = DateTime.now().millisecondsSinceEpoch
1127 + .toString();
1128 for (final tc in serverExecuted) {
1129 final eventId =
1130 '${sessionId}_1_${tc.tool}.server_executed';
1123 - toolEvents.add(ToolEvent(
1124 - eventId: eventId,
1125 - timestamp: DateTime.now(),
1126 - status: ToolEventStatus.completed,
1127 - toolName: tc.tool,
1128 - providerType: ToolProviderType.lmStudioServer,
1129 - arguments: tc.arguments,
1130 - result: tc.output,
1131 - ));
1131 + toolEvents.add(
1132 + ToolEvent(
1133 + eventId: eventId,
1134 + timestamp: DateTime.now(),
1135 + status: ToolEventStatus.completed,
1136 + toolName: tc.tool,
1137 + providerType: ToolProviderType.lmStudioServer,
1138 + arguments: tc.arguments,
1139 + result: tc.output,
1140 + ),
1141 + );
1142 }
1143 }
1144
1145 if (clientExecuted.isNotEmpty) {
1146 final registry = ref.read(toolRegistryProvider);
1137 - final adapter =
1138 - createAdapterForServerType(server.type);
1147 + final adapter = createAdapterForServerType(server.type);
1148
1149 final preParsedCalls = clientExecuted
1150 .map(
@@ -1152,15 +1161,18 @@ class ChatNotifier extends Notifier<ChatState> {
1161 registry: registry,
1162 onRequestApproval: (call) async {
1163 final completer = Completer<bool>();
1164 + final approval = PendingToolApproval(
1165 + toolCall: call,
1166 + completer: completer,
1167 + );
1168 + _pendingToolApproval = approval;
1169 state = state.copyWith(
1156 - pendingToolApproval: PendingToolApproval(
1157 - toolCall: call,
1158 - completer: completer,
1159 - ),
1170 + pendingToolApproval: approval,
1171 );
1172
1173 final result = await completer.future;
1174
1175 + _pendingToolApproval = null;
1176 state = state.copyWith(clearPendingApproval: true);
1177
1178 return result;
@@ -1190,10 +1202,7 @@ class ChatNotifier extends Notifier<ChatState> {
1202
1203 await _saveMessage(finalMessage);
1204 if (isCurrentContext) {
1193 - _replaceMessageInState(
1194 - finalMessage,
1195 - clearStreaming: true,
1196 - );
1205 + _replaceMessageInState(finalMessage, clearStreaming: true);
1206 state = state.copyWith(isStreaming: false);
1207 }
1208 ref.read(isStreamingProvider.notifier).setStreaming(false);
@@ -1219,13 +1228,18 @@ class ChatNotifier extends Notifier<ChatState> {
1228 // Auto-speak: read the response aloud if the setting
1229 // is enabled and voice mode is not already handling TTS.
1230 final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
1222 - final voiceActive = ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
1223 - if (autoSpeak && !voiceActive && finalMessage.content.trim().isNotEmpty) {
1224 - ref.read(ttsProvider.notifier).speak(
1225 - finalMessage.content,
1226 - messageId: finalMessage.id,
1227 - conversationId: finalMessage.conversationId,
1228 - );
1231 + final voiceActive =
1232 + ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
1233 + if (autoSpeak &&
1234 + !voiceActive &&
1235 + finalMessage.content.trim().isNotEmpty) {
1236 + ref
1237 + .read(ttsProvider.notifier)
1238 + .speak(
1239 + finalMessage.content,
1240 + messageId: finalMessage.id,
1241 + conversationId: finalMessage.conversationId,
1242 + );
1243 }
1244 }
1245 },
@@ -1299,15 +1313,16 @@ class ChatNotifier extends Notifier<ChatState> {
1313 }
1314
1315 final trimmedContent = content.trim();
1302 - if (trimmedContent.isEmpty && (attachments == null || attachments.isEmpty)) {
1316 + if (trimmedContent.isEmpty &&
1317 + (attachments == null || attachments.isEmpty)) {
1318 return;
1319 }
1320
1321 final titleSource = trimmedContent.isNotEmpty
1322 ? trimmedContent
1323 : (attachments?.isNotEmpty == true
1309 - ? attachments!.first.path.split(Platform.pathSeparator).last
1310 - : 'New Chat');
1324 + ? attachments!.first.path.split(Platform.pathSeparator).last
1325 + : 'New Chat');
1326 await _ensureConversationExists(titleSource);
1327
1328 final convId = _activeConversationId;
@@ -1322,15 +1337,19 @@ class ChatNotifier extends Notifier<ChatState> {
1337 }
1338
1339 for (final file in attachments) {
1325 - final savedPath =
1326 - await AttachmentHelpers.saveAttachment(file, attachmentsDir);
1340 + final savedPath = await AttachmentHelpers.saveAttachment(
1341 + file,
1342 + attachmentsDir,
1343 + );
1344 if (savedPath != null) savedPaths.add(savedPath);
1345 }
1346 }
1347
1348 final threadOrder = MessageVariants.nextThreadOrder(state.messages);
1349 final groupId = generateUuid();
1333 - final lastInTimeline = state.messages.isNotEmpty ? state.messages.last : null;
1350 + final lastInTimeline = state.messages.isNotEmpty
1351 + ? state.messages.last
1352 + : null;
1353
1354 final message = Message(
1355 id: generateUuid(),
@@ -1359,11 +1378,13 @@ class ChatNotifier extends Notifier<ChatState> {
1378 if (!_isInMemoryChat && _currentConversationId != null) {
1379 final preview = trimmedContent.isNotEmpty
1380 ? (trimmedContent.length > 100
1362 - ? '${trimmedContent.substring(0, 100)}...'
1363 - : trimmedContent)
1381 + ? '${trimmedContent.substring(0, 100)}...'
1382 + : trimmedContent)
1383 : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
1384 final timeline = state.messages;
1366 - await ref.read(conv.conversationsProvider.notifier).syncConversationStats(
1385 + await ref
1386 + .read(conv.conversationsProvider.notifier)
1387 + .syncConversationStats(
1388 _currentConversationId!,
1389 messageCount: timeline.length,
1390 characterCount: timeline.fold<int>(
@@ -1384,7 +1405,8 @@ class ChatNotifier extends Notifier<ChatState> {
1405 (selectedModel?.supportsReasoning ?? false) && !reasoningConfig.enabled;
1406
1407 final personaPrompt = _getPersonaSystemPrompt();
1387 - var systemContent = personaPrompt ??
1408 + var systemContent =
1409 + personaPrompt ??
1410 (settings.showSystemMessages
1411 ? 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.'
1412 : null);
@@ -1465,8 +1487,7 @@ class ChatNotifier extends Notifier<ChatState> {
1487
1488 try {
1489 final personas = ref.read(personasNotifierProvider).value ?? [];
1468 - final selected =
1469 - PersonaPromptUtils.resolvePersonas(personaIds, personas);
1490 + final selected = PersonaPromptUtils.resolvePersonas(personaIds, personas);
1491 if (selected.isEmpty) return null;
1492 return PersonaPromptUtils.combineSystemPrompts(selected);
1493 } catch (_) {}
@@ -1515,12 +1536,17 @@ class ChatNotifier extends Notifier<ChatState> {
1536 }
1537
1538 Future<void> _generateAndApplyTitle(String conversationId) async {
1518 - final title = await generateTitleWithAi(conversationId);
1519 - if (title == null || title.isEmpty || title == 'New Chat') return;
1539 + if (!_titleGenerationsInFlight.add(conversationId)) return;
1540 + try {
1541 + final title = await generateTitleWithAi(conversationId);
1542 + if (title == null || title.isEmpty || title == 'New Chat') return;
1543
1521 - await ref
1522 - .read(conv.conversationsProvider.notifier)
1523 - .renameConversation(conversationId, title);
1544 + await ref
1545 + .read(conv.conversationsProvider.notifier)
1546 + .renameConversation(conversationId, title);
1547 + } finally {
1548 + _titleGenerationsInFlight.remove(conversationId);
1549 + }
1550 }
1551
1552 Future<String?> generateTitleWithAi(String conversationId) async {
@@ -1566,7 +1592,8 @@ class ChatNotifier extends Notifier<ChatState> {
1592 Future<List<Message>> _loadMessagesForConversation(
1593 String conversationId,
1594 ) async {
1569 - if (_currentConversationId == conversationId && state.allMessages.isNotEmpty) {
1595 + if (_currentConversationId == conversationId &&
1596 + state.allMessages.isNotEmpty) {
1597 return MessageVariants.resolveActiveTimeline(state.allMessages);
1598 }
1599
@@ -1714,7 +1741,9 @@ class ChatNotifier extends Notifier<ChatState> {
1741 }
1742 if (userMessage == null) return;
1743
1717 - final userMessageIndex = messages.indexWhere((m) => m.id == userMessage!.id);
1744 + final userMessageIndex = messages.indexWhere(
1745 + (m) => m.id == userMessage!.id,
1746 + );
1747 final messagesToRemove = messages.sublist(userMessageIndex);
1748 final db = ref.read(databaseProvider);
1749
@@ -1727,8 +1756,9 @@ class ChatNotifier extends Notifier<ChatState> {
1756 }
1757
1758 final removedIds = messagesToRemove.map((m) => m.id).toSet();
1730 - final updatedAll =
1731 - state.allMessages.where((m) => !removedIds.contains(m.id)).toList();
1759 + final updatedAll = state.allMessages
1760 + .where((m) => !removedIds.contains(m.id))
1761 + .toList();
1762 state = state.copyWith(
1763 allMessages: updatedAll,
1764 messages: messages.sublist(0, userMessageIndex),
@@ -1737,9 +1767,7 @@ class ChatNotifier extends Notifier<ChatState> {
1767
1768 await sendMessage(
1769 userMessage.content,
1740 - attachments: userMessage.attachmentPaths
1741 - ?.map((p) => File(p))
1742 - .toList(),
1770 + attachments: userMessage.attachmentPaths?.map((p) => File(p)).toList(),
1771 );
1772 }
1773
@@ -1817,9 +1845,7 @@ class ChatNotifier extends Notifier<ChatState> {
1845 (m) => MessageVariants.groupId(m) == groupId,
1846 );
1847 if (variants.isEmpty) return 0;
1820 - return variants
1821 - .map((m) => m.variantIndex)
1822 - .reduce((a, b) => a > b ? a : b) +
1848 + return variants.map((m) => m.variantIndex).reduce((a, b) => a > b ? a : b) +
1849 1;
1850 }
1851
@@ -1943,10 +1969,10 @@ class ChatNotifier extends Notifier<ChatState> {
1969 var isFirstContinueChunk = continueGeneration;
1970
1971 final mcpConfig = ref.read(chatMcpConfigProvider);
1946 - final activeIntegrations =
1947 - mcpConfig.integrations.where((i) => i.enabled).toList();
1948 - final integrations =
1949 - mcpConfig.enabled && activeIntegrations.isNotEmpty
1972 + final activeIntegrations = mcpConfig.integrations
1973 + .where((i) => i.enabled)
1974 + .toList();
1975 + final integrations = mcpConfig.enabled && activeIntegrations.isNotEmpty
1976 ? activeIntegrations
1977 : null;
1978
@@ -1997,10 +2023,11 @@ class ChatNotifier extends Notifier<ChatState> {
2023 isFirstContinueChunk = false;
2024 final existing = streamingAssistantMessage.content;
2025 if (existing.isNotEmpty &&
2000 - !RegExp(r'[\s\p{P}]$', unicode: true)
2001 - .hasMatch(existing) &&
2002 - !RegExp(r'^[\s\p{P}]', unicode: true)
2003 - .hasMatch(delta)) {
2026 + !RegExp(
2027 + r'[\s\p{P}]$',
2028 + unicode: true,
2029 + ).hasMatch(existing) &&
2030 + !RegExp(r'^[\s\p{P}]', unicode: true).hasMatch(delta)) {
2031 delta = ' $delta';
2032 }
2033 }
@@ -2103,7 +2130,10 @@ class ChatNotifier extends Notifier<ChatState> {
2130 await _saveMessage(finalMessage);
2131 if (isCurrentContext) {
2132 _replaceMessageInAll(finalMessage, clearStreaming: true);
2106 - state = state.copyWith(isStreaming: false, clearStreaming: true);
2133 + state = state.copyWith(
2134 + isStreaming: false,
2135 + clearStreaming: true,
2136 + );
2137 }
2138 ref.read(isStreamingProvider.notifier).setStreaming(false);
2139 ref.read(chatBackgroundServiceProvider).stop();
@@ -2121,12 +2151,14 @@ class ChatNotifier extends Notifier<ChatState> {
2151 // Auto-speak: read the response aloud if the setting is
2152 // enabled and voice mode is not already handling TTS.
2153 final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
2124 - final voiceActive = ref.read(voiceModeProvider).phase !=
2125 - VoiceModePhase.idle;
2154 + final voiceActive =
2155 + ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
2156 if (autoSpeak &&
2157 !voiceActive &&
2158 finalMessage.content.trim().isNotEmpty) {
2129 - ref.read(ttsProvider.notifier).speak(
2159 + ref
2160 + .read(ttsProvider.notifier)
2161 + .speak(
2162 finalMessage.content,
2163 messageId: finalMessage.id,
2164 conversationId: finalMessage.conversationId,
@@ -2223,7 +2255,9 @@ class ChatNotifier extends Notifier<ChatState> {
2255 activeConv.topP != null ||
2256 activeConv.maxTokens != null ||
2257 activeConv.contextLength != null) {
2226 - await ref.read(conv.conversationsProvider.notifier).updateChatParams(
2258 + await ref
2259 + .read(conv.conversationsProvider.notifier)
2260 + .updateChatParams(
2261 newConversation.id,
2262 temperature: activeConv.temperature,
2263 topP: activeConv.topP,
@@ -2245,7 +2279,9 @@ class ChatNotifier extends Notifier<ChatState> {
2279 ? '${lastMessage.content.substring(0, 100)}...'
2280 : lastMessage.content;
2281
2248 - await ref.read(conv.conversationsProvider.notifier).updatePreview(
2282 + await ref
2283 + .read(conv.conversationsProvider.notifier)
2284 + .updatePreview(
2285 newConversation.id,
2286 preview,
2287 DateTime.now(),
@@ -2275,8 +2311,9 @@ class ChatNotifier extends Notifier<ChatState> {
2311 db.messageBox.removeMany(query.findIds());
2312 query.close();
2313
2278 - final updatedAll =
2279 - state.allMessages.where((m) => m.id != messageId).toList();
2314 + final updatedAll = state.allMessages
2315 + .where((m) => m.id != messageId)
2316 + .toList();
2317 state = state.copyWith(
2318 allMessages: updatedAll,
2319 messages: MessageVariants.resolveActiveTimeline(updatedAll),
@@ -2329,7 +2366,9 @@ class ChatNotifier extends Notifier<ChatState> {
2366 final chatService = ref.read(chatServiceProvider);
2367 if (server == null || chatService == null || selectedModel == null) return;
2368
2332 - final generated = await ref.read(smartReplyServiceProvider).generateUserMessage(
2369 + final generated = await ref
2370 + .read(smartReplyServiceProvider)
2371 + .generateUserMessage(
2372 chatService: chatService,
2373 server: server,
2374 modelId: selectedModel.id,
@@ -2356,10 +2395,10 @@ class ChatNotifier extends Notifier<ChatState> {
2395 final title = settings.autoGenerateTitle
2396 ? 'New Chat'
2397 : ref
2359 - .read(titleGenerationServiceProvider)
2360 - .truncateFirstMessageTitle(
2361 - firstUser.isNotEmpty ? firstUser : 'New Chat',
2362 - );
2398 + .read(titleGenerationServiceProvider)
2399 + .truncateFirstMessageTitle(
2400 + firstUser.isNotEmpty ? firstUser : 'New Chat',
2401 + );
2402
2403 final preselected = ref.read(selectedPersonasProvider);
2404 final conversation = await ref
@@ -2384,7 +2423,7 @@ class ChatNotifier extends Notifier<ChatState> {
2423 _ephemeralConversationId = null;
2424 state = state.copyWith(isTemporary: false);
2425 ref
2387 - .read(conv.activeConversationProvider.notifier)
2426 + .read(conv.activeConversationIdProvider.notifier)
2427 .setActiveConversation(conversation);
2428
2429 final updatedAll = state.allMessages
@@ -2492,7 +2531,7 @@ class ChatNotifier extends Notifier<ChatState> {
2531 _pendingTemporaryChat = false;
2532 state = const ChatState();
2533 ref
2495 - .read(conv.activeConversationProvider.notifier)
2534 + .read(conv.activeConversationIdProvider.notifier)
2535 .setActiveConversation(null);
2536 }
2537
@@ -2504,7 +2543,5 @@ class ChatNotifier extends Notifier<ChatState> {
2543 final hasActiveChatSessionProvider = Provider<bool>((ref) {
2544 final chat = ref.watch(chatProvider);
2545 final activeConv = ref.watch(conv.activeConversationProvider);
2507 - return chat.messages.isNotEmpty ||
2508 - chat.isStreaming ||
2509 - activeConv != null;
2546 + return chat.messages.isNotEmpty || chat.isStreaming || activeConv != null;
2547 });
lib/features/chat/providers/model_selection_providers.dart
+18
@@ -25,6 +25,24 @@ class SelectedModelNotifier extends Notifier<ModelInfo?> {
25 void setModel(ModelInfo? model) {
26 state = model;
27 if (model != null) {
28 + if (model.serverType == ServerType.onDevice) {
29 + final engineState = ref.read(onDeviceEngineProvider);
30 + if (engineState.loadedModelId != null &&
31 + engineState.status == OnDeviceEngineStatus.loaded) {
32 + final activeServer = ref.read(activeServerProvider);
33 + if (activeServer?.type != ServerType.onDevice) {
34 + final servers = ref.read(serversProvider).value ?? [];
35 + final onDeviceServer = servers
36 + .where((s) => s.type == ServerType.onDevice)
37 + .firstOrNull;
38 + if (onDeviceServer != null) {
39 + ref
40 + .read(activeServerIdProvider.notifier)
41 + .setActiveServer(onDeviceServer);
42 + }
43 + }
44 + }
45 + }
46 _normalizeReasoningForModel(model);
47 }
48 }
lib/features/conversations/providers/conversation_providers.dart
+77 -46
@@ -1,3 +1,4 @@
1 +import 'dart:async';
2 import 'dart:math';
3
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -33,6 +34,31 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
34 );
35 }
36
37 + Future<void> _updateState() async {
38 + final data = await _loadAll();
39 + if (ref.mounted) {
40 + state = AsyncData(data);
41 + }
42 + }
43 +
44 + void _updateInState(Conversation updated) {
45 + final conversations = state.value;
46 + if (conversations == null) {
47 + unawaited(_updateState());
48 + return;
49 + }
50 + final updatedList = conversations
51 + .map((c) => c.id == updated.id ? updated : c)
52 + .toList();
53 + updatedList.sort((a, b) {
54 + if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
55 + return b.updatedAt.compareTo(a.updatedAt);
56 + });
57 + if (ref.mounted) {
58 + state = AsyncData(updatedList);
59 + }
60 + }
61 +
62 static List<Conversation> _loadConversationsInBackground(Store store, _) {
63 final convBox = store.box<ConversationEntity>();
64 final msgBox = store.box<MessageEntity>();
@@ -107,7 +133,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
133 );
134
135 db.conversationBox.put(ConversationEntity.fromDomain(conversation));
110 - state = AsyncData(await _loadAll());
136 + await _updateState();
137 return conversation;
138 }
139
@@ -134,7 +160,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
160 }
161 db.conversationBox.put(entity);
162
137 - state = AsyncData(await _loadAll());
163 + _updateInState(updated);
164 }
165 }
166
@@ -162,7 +188,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
188 }
189 db.conversationBox.put(entity);
190
165 - state = AsyncData(await _loadAll());
191 + _updateInState(updated);
192 }
193
194 Future<void> setTemporary(String id, bool isTemporary) async {
@@ -190,7 +216,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
216 }
217 db.conversationBox.put(entity);
218
193 - state = AsyncData(await _loadAll());
219 + _updateInState(updated);
220 }
221
222 Future<void> deleteConversation(String id) async {
@@ -210,7 +236,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
236 db.conversationBox.removeMany(convQuery.findIds());
237 convQuery.close();
238
213 - state = AsyncData(await _loadAll());
239 + await _updateState();
240 }
241
242 Future<void> togglePin(String id) async {
@@ -238,7 +264,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
264 }
265 db.conversationBox.put(entity);
266
241 - state = AsyncData(await _loadAll());
267 + _updateInState(updated);
268 }
269
270 Future<void> setArchived(String id, bool archived) async {
@@ -266,7 +292,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
292 }
293 db.conversationBox.put(entity);
294
269 - state = AsyncData(await _loadAll());
295 + _updateInState(updated);
296 }
297
298 Future<void> updatePreview(
@@ -304,7 +330,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
330 }
331 db.conversationBox.put(entity);
332
307 - state = AsyncData(await _loadAll());
333 + await _updateState();
334 }
335 }
336
@@ -338,7 +364,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
364 entity.internalId = existingEntity.internalId;
365 }
366 db.conversationBox.put(entity);
341 - state = AsyncData(await _loadAll());
367 + await _updateState();
368 }
369
370 /// Updates just the embeddings-derived total token count without
@@ -365,7 +391,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
391 entity.internalId = existingEntity.internalId;
392 }
393 db.conversationBox.put(entity);
368 - state = AsyncData(await _loadAll());
394 + await _updateState();
395 }
396
397 Future<void> updatePersonas(String id, List<Persona> personas) async {
@@ -408,7 +434,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
434 }
435 db.conversationBox.put(entity);
436
411 - state = AsyncData(await _loadAll());
437 + await _updateState();
438 }
439 }
440
@@ -452,7 +478,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
478 }
479 db.conversationBox.put(entity);
480
455 - state = AsyncData(await _loadAll());
481 + await _updateState();
482 }
483 }
484
@@ -483,7 +509,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
509 }
510 db.conversationBox.put(entity);
511
486 - state = AsyncData(await _loadAll());
512 + await _updateState();
513 }
514 }
515
@@ -491,7 +517,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
517 final db = ref.read(databaseProvider);
518 db.messageBox.removeAll();
519 db.conversationBox.removeAll();
494 - state = AsyncData(await _loadAll());
520 + await _updateState();
521 }
522
523 Future<Conversation> duplicateConversation(String id) async {
@@ -535,7 +561,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
561 }
562 }
563
538 - state = AsyncData(await _loadAll());
564 + await _updateState();
565 return duplicate;
566 }
567
@@ -558,7 +584,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
584 final entity = ConversationEntity.fromDomain(updated);
585 if (existingEntity != null) entity.internalId = existingEntity.internalId;
586 db.conversationBox.put(entity);
561 - state = AsyncData(await _loadAll());
587 + await _updateState();
588 }
589
590 String _generateUuid() {
@@ -570,44 +596,42 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
596 }
597 }
598
573 -final activeConversationProvider =
574 - NotifierProvider<ActiveConversationNotifier, Conversation?>(() {
575 - return ActiveConversationNotifier();
599 +final activeConversationIdProvider =
600 + NotifierProvider<ActiveConversationIdNotifier, String?>(() {
601 + return ActiveConversationIdNotifier();
602 });
603
578 -class ActiveConversationNotifier extends Notifier<Conversation?> {
579 - String? _activeConversationId;
580 -
604 +class ActiveConversationIdNotifier extends Notifier<String?> {
605 @override
582 - Conversation? build() {
583 - // Use ref.listen instead of ref.watch to avoid triggering invalidateSelf
584 - // during a rebuild cycle, which causes a Riverpod assertion error on
585 - // pausedActiveSubscriptionCount.
586 - ref.listen<AsyncValue<List<Conversation>>>(conversationsProvider, (prev, next) {
587 - if (next.hasValue) {
588 - Future.microtask(() => _updateFromConversations(next.value!));
589 - }
590 - });
606 + String? build() => null;
607
592 - final conversationsAsync = ref.read(conversationsProvider);
593 - final conversations = conversationsAsync.value ?? [];
594 - return _resolveConversation(conversations, _activeConversationId);
608 + void setActiveConversationId(String? id) {
609 + state = id;
610 }
611
597 - Conversation? _resolveConversation(List<Conversation> conversations, String? id) {
598 - if (id == null) return null;
599 - return conversations.where((c) => c.id == id).firstOrNull;
612 + void setActiveConversation(Conversation? conversation) {
613 + state = conversation?.id;
614 }
615 +}
616
602 - void _updateFromConversations(List<Conversation> conversations) {
603 - final resolved = _resolveConversation(conversations, _activeConversationId);
604 - if (resolved?.id != state?.id) {
605 - state = resolved;
606 - }
617 +final activeConversationProvider =
618 + NotifierProvider<ActiveConversationNotifier, Conversation?>(() {
619 + return ActiveConversationNotifier();
620 + });
621 +
622 +class ActiveConversationNotifier extends Notifier<Conversation?> {
623 + @override
624 + Conversation? build() {
625 + final activeId = ref.watch(activeConversationIdProvider);
626 + if (activeId == null) return null;
627 + final conversations = ref.watch(conversationsProvider).value ?? [];
628 + return conversations.where((c) => c.id == activeId).firstOrNull;
629 }
630
631 void setActiveConversation(Conversation? conversation) {
610 - _activeConversationId = conversation?.id;
632 + ref
633 + .read(activeConversationIdProvider.notifier)
634 + .setActiveConversation(conversation);
635 state = conversation;
636 }
637 }
@@ -823,6 +847,13 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
847 );
848 }
849
850 + Future<void> _updateState() async {
851 + final data = await _loadAll();
852 + if (ref.mounted) {
853 + state = AsyncData(data);
854 + }
855 + }
856 +
857 static List<ConversationFolder> _loadFoldersInBackground(Store store, _) {
858 final entities = store.box<ConversationFolderEntity>().getAll();
859 final folders = entities
@@ -855,7 +886,7 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
886 createdAt: folder.createdAt,
887 ),
888 );
858 - state = AsyncData(await _loadAll());
889 + await _updateState();
890 return folder;
891 }
892
@@ -869,7 +900,7 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
900 if (entity == null) return;
901 entity.name = name;
902 db.conversationFolderBox.put(entity);
872 - state = AsyncData(await _loadAll());
903 + await _updateState();
904 }
905
906 Future<void> deleteFolder(String id) async {
@@ -890,7 +921,7 @@ class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>
921 convQuery.close();
922
923 ref.invalidate(conversationsProvider);
893 - state = AsyncData(await _loadAll());
924 + await _updateState();
925 }
926 }
927
lib/features/on_device/data/on_device_chat_service.dart
+269 -253
@@ -4,7 +4,6 @@ import 'package:flutter_gemma/flutter_gemma.dart' as gemma;
4
5 import '../../../core/logger/app_logger.dart';
6 import '../../../core/models/enums.dart';
7 -import '../../../core/services/crash_report_service.dart';
7 import '../../../core/utils/bpe_decoder.dart';
8 import '../../chat/data/chat_service.dart';
9 import '../../chat/data/models/chat_parameters.dart';
@@ -15,19 +14,12 @@ import '../../servers/data/models/server.dart';
14 import 'on_device_gemma_service.dart';
15
16 class OnDeviceChatService implements ChatService {
18 - final OnDeviceGemmaService _gemmaService;
19 - StreamSubscription<gemma.ModelResponse>? _currentSubscription;
20 - StreamController<ChatResponse>? _streamController;
21 - gemma.InferenceChat? _activeChat;
22 - String? _activeModelId;
23 - String? _activeSystemInstruction;
24 - // Safe in Dart's single-threaded event loop: reads/writes are not
25 - // interrupted by other isolates, and async boundaries only yield control
26 - // at `await` points — so simple `bool` state is sufficient.
27 - bool _isCancelled = false;
28 -
17 OnDeviceChatService(this._gemmaService);
18
19 + final OnDeviceInferenceService _gemmaService;
20 + final Set<_InferenceRun> _activeRuns = <_InferenceRun>{};
21 + bool _isDisposed = false;
22 +
23 @override
24 Stream<ChatResponse> sendMessage({
25 required Server server,
@@ -38,260 +30,277 @@ class OnDeviceChatService implements ChatService {
30 List<ToolDefinition>? tools,
31 bool continueGeneration = false,
32 }) {
41 - cancelStream();
42 - _isCancelled = false;
43 - _streamController = StreamController<ChatResponse>();
33 + late final _InferenceRun run;
34 + final controller = StreamController<ChatResponse>();
35 + run = _InferenceRun(controller);
36 + controller.onCancel = () => _cancelRun(run, notifyListener: false);
37
45 - _startInference(modelId, messages, params);
38 + if (_isDisposed) {
39 + scheduleMicrotask(() async {
40 + controller.add(
41 + const ChatResponse(
42 + type: ChatResponseType.error,
43 + content: 'On-device chat service has been disposed',
44 + ),
45 + );
46 + controller.add(const ChatResponse(type: ChatResponseType.done));
47 + await controller.close();
48 + });
49 + return controller.stream;
50 + }
51
47 - return _streamController!.stream;
52 + _activeRuns.add(run);
53 + unawaited(_startInference(run, modelId, messages, params));
54 + return controller.stream;
55 }
56
57 Future<void> _startInference(
58 + _InferenceRun run,
59 String modelId,
60 List<Message> messages,
61 ChatParameters params,
62 ) async {
63 try {
56 - if (!_gemmaService.isLoaded || _isCancelled) {
57 - _streamController?.add(
58 - const ChatResponse(
59 - type: ChatResponseType.error,
60 - content: 'Model not loaded',
61 - ),
62 - );
63 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
64 - await _streamController?.close();
65 - return;
66 - }
67 -
64 final systemInstruction = params.systemPrompt?.isNotEmpty == true
65 ? params.systemPrompt
66 : null;
67 + final session = await _gemmaService.createChat(
68 + systemInstruction: systemInstruction,
69 + tools: const [],
70 + );
71 + run.session = session;
72
72 - final gemmaMessages = _convertMessages(messages);
73 -
74 - bool canReuse = false;
75 - List<gemma.Message> newMessagesToFeed = [];
76 -
77 - if (_activeChat != null &&
78 - _activeModelId == modelId &&
79 - _activeSystemInstruction == systemInstruction) {
80 - final existingHistory = _activeChat!.fullHistory;
81 - if (existingHistory.length <= gemmaMessages.length) {
82 - bool isPrefix = true;
83 - for (int i = 0; i < existingHistory.length; i++) {
84 - if (existingHistory[i].text != gemmaMessages[i].text ||
85 - existingHistory[i].isUser != gemmaMessages[i].isUser) {
86 - isPrefix = false;
87 - break;
88 - }
89 - }
90 - if (isPrefix) {
91 - canReuse = true;
92 - newMessagesToFeed = gemmaMessages.sublist(existingHistory.length);
93 - }
94 - }
73 + if (run.isCancelled) {
74 + await _closeSession(run, stopGeneration: false);
75 + return;
76 }
77
97 - gemma.InferenceChat chat;
98 - if (canReuse) {
99 - chat = _activeChat!;
100 - Log.debug('Reusing existing active chat session for on-device inference.');
101 - for (final msg in newMessagesToFeed) {
102 - if (_isCancelled) break;
103 - await chat.addQueryChunk(msg);
104 - }
105 - } else {
106 - Log.debug('Creating new chat session for on-device inference.');
107 - if (_activeChat != null) {
108 - _disposeChat(_activeChat!);
109 - _activeChat = null;
110 - }
111 -
112 - final newChat = await _gemmaService.createChat(
113 - systemInstruction: systemInstruction,
114 - tools: const [],
115 - );
116 -
117 - if (newChat == null) {
118 - _streamController?.add(
119 - const ChatResponse(
120 - type: ChatResponseType.error,
121 - content: 'Failed to create chat session',
122 - ),
123 - );
124 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
125 - await _streamController?.close();
78 + for (final message in _convertMessages(messages)) {
79 + if (run.isCancelled) {
80 + await _closeSession(run, stopGeneration: false);
81 return;
82 }
128 -
129 - chat = newChat;
130 - _activeChat = chat;
131 - _activeModelId = modelId;
132 - _activeSystemInstruction = systemInstruction;
133 -
134 - for (final msg in gemmaMessages) {
135 - if (_isCancelled) break;
136 - await chat.addQueryChunk(msg);
137 - }
83 + await session.addQueryChunk(message);
84 }
85
140 - if (_isCancelled) {
141 - _disposeChat(chat);
142 - _activeChat = null;
143 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
144 - await _streamController?.close();
86 + if (run.isCancelled) {
87 + await _closeSession(run, stopGeneration: false);
88 return;
89 }
90
148 - // Start streaming response
149 - final responseStream = chat.generateChatResponseAsync();
150 - final completer = Completer<void>();
151 -
152 - final isBpeModel = modelId.toLowerCase().contains('qwen') ||
91 + final isBpeModel =
92 + modelId.toLowerCase().contains('qwen') ||
93 modelId.toLowerCase().contains('deepseek');
154 - final textDecoder = isBpeModel ? BpeDecoder() : null;
155 - final reasoningDecoder = isBpeModel ? BpeDecoder() : null;
156 -
157 - _currentSubscription = responseStream.listen(
158 - (response) {
159 - if (_isCancelled) return;
94 + run.textDecoder = isBpeModel ? BpeDecoder() : null;
95 + run.reasoningDecoder = isBpeModel ? BpeDecoder() : null;
96 + run.generationStarted = true;
97
161 - try {
162 - if (response is gemma.TextResponse) {
163 - if (response.token.isNotEmpty) {
164 - final content = textDecoder != null
165 - ? textDecoder.decodeChunk(response.token)
166 - : response.token;
167 - if (content.isNotEmpty) {
168 - _streamController?.add(
169 - ChatResponse(
170 - type: ChatResponseType.message,
171 - content: content,
172 - ),
173 - );
174 - }
175 - }
176 - } else if (response is gemma.FunctionCallResponse) {
177 - _streamController?.add(
178 - ChatResponse(
179 - type: ChatResponseType.toolCall,
180 - toolCall: ToolCallData(
181 - tool: response.name,
182 - arguments: response.args,
183 - ),
184 - ),
185 - );
186 - } else if (response is gemma.ThinkingResponse) {
187 - if (response.content.isNotEmpty) {
188 - final reasoningContent = reasoningDecoder != null
189 - ? reasoningDecoder.decodeChunk(response.content)
190 - : response.content;
191 - if (reasoningContent.isNotEmpty) {
192 - _streamController?.add(
193 - ChatResponse(
194 - type: ChatResponseType.reasoning,
195 - reasoningContent: reasoningContent,
196 - ),
197 - );
198 - }
199 - }
200 - }
201 - } catch (error, stackTrace) {
202 - Log.error('OnDevice stream handling error: $error\n$stackTrace');
203 - CrashReportService.instance.capture(error, stackTrace);
204 - if (!_isCancelled && !(_streamController?.isClosed ?? true)) {
205 - _streamController?.add(
206 - ChatResponse(
207 - type: ChatResponseType.error,
208 - content: 'Inference error: ${error.toString()}',
209 - ),
210 - );
211 - _streamController?.add(
212 - const ChatResponse(type: ChatResponseType.done),
213 - );
214 - _streamController?.close();
215 - }
216 - }
217 - },
218 - onDone: () {
219 - if (!_isCancelled) {
220 - final finalToken = textDecoder?.flush() ?? '';
221 - final finalReasoning = reasoningDecoder?.flush() ?? '';
222 - if (finalToken.isNotEmpty) {
223 - _streamController?.add(
224 - ChatResponse(
225 - type: ChatResponseType.message,
226 - content: finalToken,
227 - ),
228 - );
229 - }
230 - if (finalReasoning.isNotEmpty) {
231 - _streamController?.add(
232 - ChatResponse(
233 - type: ChatResponseType.reasoning,
234 - reasoningContent: finalReasoning,
235 - ),
236 - );
237 - }
238 - _streamController?.add(
239 - const ChatResponse(type: ChatResponseType.done),
240 - );
241 - _streamController?.close();
242 - }
243 - _currentSubscription = null;
244 - if (!completer.isCompleted) completer.complete();
98 + final completer = Completer<void>();
99 + run.streamCompleter = completer;
100 + run.subscription = session.generateChatResponseAsync().listen(
101 + (response) => _handleResponse(run, response),
102 + onDone: () async {
103 + run.subscription = null;
104 + await _finishSuccessfully(run);
105 },
246 - onError: (error, stackTrace) {
106 + onError: (Object error, StackTrace stackTrace) async {
107 + run.subscription = null;
108 Log.error('OnDevice stream error: $error');
248 - CrashReportService.instance.capture(
249 - error,
250 - stackTrace is StackTrace ? stackTrace : StackTrace.current,
251 - );
252 - if (!_isCancelled && !(_streamController?.isClosed ?? true)) {
253 - _streamController?.add(
254 - ChatResponse(
255 - type: ChatResponseType.error,
256 - content: 'Inference error: ${error.toString()}',
257 - ),
258 - );
259 - _streamController?.add(
260 - const ChatResponse(type: ChatResponseType.done),
261 - );
262 - _streamController?.close();
263 - }
264 - _disposeChat(chat);
265 - if (_activeChat == chat) {
266 - _activeChat = null;
267 - }
268 - _currentSubscription = null;
269 - if (!completer.isCompleted) completer.complete();
109 + await _finishWithError(run, 'Inference error: $error');
110 },
111 cancelOnError: true,
112 );
273 -
113 await completer.future;
275 - } catch (e, stackTrace) {
276 - Log.error('OnDevice inference error: $e');
277 - CrashReportService.instance.capture(e, stackTrace);
278 - if (!(_streamController?.isClosed ?? true)) {
279 - _streamController?.add(
114 + } catch (error, stackTrace) {
115 + if (run.isCancelled) {
116 + await _closeSession(run, stopGeneration: run.generationStarted);
117 + return;
118 + }
119 + Log.error('OnDevice inference error: $error\n$stackTrace');
120 + await _finishWithError(run, 'Inference error: $error');
121 + }
122 + }
123 +
124 + void _handleResponse(_InferenceRun run, gemma.ModelResponse response) {
125 + if (run.isCancelled || run.controller.isClosed) return;
126 +
127 + try {
128 + if (response is gemma.TextResponse && response.token.isNotEmpty) {
129 + final content =
130 + run.textDecoder?.decodeChunk(response.token) ?? response.token;
131 + if (content.isNotEmpty) {
132 + run.controller.add(
133 + ChatResponse(type: ChatResponseType.message, content: content),
134 + );
135 + }
136 + } else if (response is gemma.FunctionCallResponse) {
137 + run.controller.add(
138 ChatResponse(
281 - type: ChatResponseType.error,
282 - content: 'Inference error: ${e.toString()}',
139 + type: ChatResponseType.toolCall,
140 + toolCall: ToolCallData(
141 + tool: response.name,
142 + arguments: response.args,
143 + ),
144 ),
145 );
285 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
286 - await _streamController?.close();
146 + } else if (response is gemma.ThinkingResponse &&
147 + response.content.isNotEmpty) {
148 + final content =
149 + run.reasoningDecoder?.decodeChunk(response.content) ??
150 + response.content;
151 + if (content.isNotEmpty) {
152 + run.controller.add(
153 + ChatResponse(
154 + type: ChatResponseType.reasoning,
155 + reasoningContent: content,
156 + ),
157 + );
158 + }
159 + }
160 + } catch (error, stackTrace) {
161 + Log.error('OnDevice stream handling error: $error\n$stackTrace');
162 + unawaited(
163 + _finishWithError(run, 'Inference error: $error', abortGeneration: true),
164 + );
165 + }
166 + }
167 +
168 + Future<void> _finishSuccessfully(_InferenceRun run) async {
169 + if (!run.beginFinishing()) return;
170 +
171 + if (!run.isCancelled && !run.controller.isClosed) {
172 + try {
173 + final finalToken = run.textDecoder?.flush() ?? '';
174 + final finalReasoning = run.reasoningDecoder?.flush() ?? '';
175 + if (finalToken.isNotEmpty) {
176 + run.controller.add(
177 + ChatResponse(type: ChatResponseType.message, content: finalToken),
178 + );
179 + }
180 + if (finalReasoning.isNotEmpty) {
181 + run.controller.add(
182 + ChatResponse(
183 + type: ChatResponseType.reasoning,
184 + reasoningContent: finalReasoning,
185 + ),
186 + );
187 + }
188 + } catch (error) {
189 + Log.error('Error flushing BPE decoders: $error');
190 + }
191 + }
192 +
193 + await _closeSession(run, stopGeneration: false);
194 + await _closeController(run, notifyListener: !run.isCancelled);
195 + _activeRuns.remove(run);
196 + _completeRunFuture(run);
197 + }
198 +
199 + Future<void> _finishWithError(
200 + _InferenceRun run,
201 + String message, {
202 + bool abortGeneration = false,
203 + }) async {
204 + if (!run.beginFinishing()) return;
205 +
206 + if (abortGeneration) {
207 + final session = run.session;
208 + if (session != null && run.generationStarted) {
209 + try {
210 + await session.stopGeneration();
211 + } catch (error) {
212 + Log.warning('Error stopping failed on-device generation: $error');
213 + }
214 + }
215 + final subscription = run.subscription;
216 + run.subscription = null;
217 + await subscription?.cancel();
218 + }
219 + await _closeSession(run, stopGeneration: false);
220 + if (!run.isCancelled && !run.controller.isClosed) {
221 + run.controller.add(
222 + ChatResponse(type: ChatResponseType.error, content: message),
223 + );
224 + }
225 + await _closeController(run, notifyListener: !run.isCancelled);
226 + _activeRuns.remove(run);
227 + _completeRunFuture(run);
228 + }
229 +
230 + Future<void> _cancelRun(
231 + _InferenceRun run, {
232 + required bool notifyListener,
233 + }) async {
234 + // StreamController.onCancel may re-enter while cleanup is closing the
235 + // controller. Waiting on that same cleanup future would deadlock the
236 + // controller close, so repeated cancellation is deliberately a no-op.
237 + if (run.isCancelled) return;
238 + run.isCancelled = true;
239 + if (!run.beginFinishing()) {
240 + return;
241 + }
242 +
243 + final cleanup = () async {
244 + final subscription = run.subscription;
245 + run.subscription = null;
246 + if (run.generationStarted) {
247 + final session = run.session;
248 + if (session != null) {
249 + try {
250 + await session.stopGeneration();
251 + } catch (error) {
252 + Log.warning('Error stopping on-device generation: $error');
253 + }
254 + }
255 }
256 + await subscription?.cancel();
257 + await _closeSession(run, stopGeneration: false);
258 + await _closeController(run, notifyListener: notifyListener);
259 + _activeRuns.remove(run);
260 + _completeRunFuture(run);
261 + }();
262 + await cleanup;
263 + }
264 +
265 + void _completeRunFuture(_InferenceRun run) {
266 + final completer = run.streamCompleter;
267 + run.streamCompleter = null;
268 + if (completer != null && !completer.isCompleted) {
269 + completer.complete();
270 }
271 }
272
291 - void _disposeChat(gemma.InferenceChat chat) {
292 - chat.close().catchError((e) {
293 - Log.error('Error disposing chat: $e');
294 - });
273 + Future<void> _closeSession(
274 + _InferenceRun run, {
275 + required bool stopGeneration,
276 + }) async {
277 + final session = run.session;
278 + run.session = null;
279 + if (session == null) return;
280 +
281 + if (stopGeneration) {
282 + try {
283 + await session.stopGeneration();
284 + } catch (error) {
285 + Log.warning('Error stopping on-device generation: $error');
286 + }
287 + }
288 + try {
289 + await session.close();
290 + } catch (error) {
291 + Log.warning('Error closing on-device chat session: $error');
292 + }
293 + }
294 +
295 + Future<void> _closeController(
296 + _InferenceRun run, {
297 + required bool notifyListener,
298 + }) async {
299 + if (run.controller.isClosed) return;
300 + if (notifyListener) {
301 + run.controller.add(const ChatResponse(type: ChatResponseType.done));
302 + }
303 + await run.controller.close();
304 }
305
306 List<gemma.Message> _convertMessages(List<Message> messages) {
@@ -303,15 +312,15 @@ class OnDeviceChatService implements ChatService {
312 }
313 return list
314 .where(
306 - (m) =>
307 - m.role == MessageRole.user ||
308 - m.role == MessageRole.assistant ||
309 - m.role == MessageRole.system,
315 + (message) =>
316 + message.role == MessageRole.user ||
317 + message.role == MessageRole.assistant ||
318 + message.role == MessageRole.system,
319 )
320 .map(
312 - (m) => gemma.Message.text(
313 - text: m.content,
314 - isUser: m.role == MessageRole.user,
321 + (message) => gemma.Message.text(
322 + text: message.content,
323 + isUser: message.role == MessageRole.user,
324 ),
325 )
326 .toList();
@@ -319,27 +328,34 @@ class OnDeviceChatService implements ChatService {
328
329 @override
330 void cancelStream() {
322 - _isCancelled = true;
323 -
324 - _currentSubscription?.cancel();
325 - _currentSubscription = null;
326 -
327 - if (_activeChat != null) {
328 - _activeChat!.stopGeneration().catchError((e) {
329 - Log.error('Error stopping generation: $e');
330 - });
331 - }
332 -
333 - if (!(_streamController?.isClosed ?? true)) {
334 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
335 - _streamController?.close();
331 + for (final run in _activeRuns.toList()) {
332 + unawaited(_cancelRun(run, notifyListener: true));
333 }
334 }
335
336 void dispose() {
340 - if (_activeChat != null) {
341 - _disposeChat(_activeChat!);
342 - _activeChat = null;
343 - }
337 + if (_isDisposed) return;
338 + _isDisposed = true;
339 + cancelStream();
340 + }
341 +}
342 +
343 +class _InferenceRun {
344 + _InferenceRun(this.controller);
345 +
346 + final StreamController<ChatResponse> controller;
347 + StreamSubscription<gemma.ModelResponse>? subscription;
348 + OnDeviceInferenceSession? session;
349 + Completer<void>? streamCompleter;
350 + BpeDecoder? textDecoder;
351 + BpeDecoder? reasoningDecoder;
352 + bool generationStarted = false;
353 + bool isCancelled = false;
354 + bool _isFinishing = false;
355 +
356 + bool beginFinishing() {
357 + if (_isFinishing) return false;
358 + _isFinishing = true;
359 + return true;
360 }
361 }
lib/features/on_device/data/on_device_gemma_service.dart
+144 -15
@@ -7,17 +7,79 @@ import 'package:path_provider/path_provider.dart';
7 import '../../../core/logger/app_logger.dart';
8 import 'models/on_device_model.dart';
9
10 -class OnDeviceGemmaService {
10 +abstract interface class OnDeviceInferenceSession {
11 + Future<void> addQueryChunk(Message message);
12 +
13 + Stream<ModelResponse> generateChatResponseAsync();
14 +
15 + Future<void> stopGeneration();
16 +
17 + Future<void> close();
18 +}
19 +
20 +abstract interface class OnDeviceInferenceService {
21 + bool get isLoaded;
22 +
23 + Future<OnDeviceInferenceSession> createChat({
24 + String? systemInstruction,
25 + List<Tool> tools,
26 + });
27 +}
28 +
29 +class _GemmaInferenceSession implements OnDeviceInferenceSession {
30 + _GemmaInferenceSession(this._chat);
31 +
32 + final InferenceChat _chat;
33 +
34 + @override
35 + Future<void> addQueryChunk(Message message) => _chat.addQueryChunk(message);
36 +
37 + @override
38 + Stream<ModelResponse> generateChatResponseAsync() =>
39 + _chat.generateChatResponseAsync();
40 +
41 + @override
42 + Future<void> stopGeneration() => _chat.stopGeneration();
43 +
44 + @override
45 + Future<void> close() => _chat.close();
46 +}
47 +
48 +class OnDeviceGemmaService implements OnDeviceInferenceService {
49 InferenceModel? _model;
50 String? _currentModelId;
51 PreferredBackend? _currentBackend;
52 bool _isDisposed = false;
53 + Future<void>? _recoveryFuture;
54
55 InferenceModel? get activeModel => _model;
56 String? get currentModelId => _currentModelId;
57 + @override
58 bool get isLoaded => _model != null && !_isDisposed;
59 bool get isDisposed => _isDisposed;
60
61 + /// The filename of the model restored by the native model manager on app
62 + /// restart (e.g. "Qwen3-0.6B.litertlm"), or null if no active model spec is
63 + /// set. This is a cheap synchronous read that does NOT load the engine.
64 + String? get restoredActiveModelName => FlutterGemma.activeModelSpec?.name;
65 +
66 + /// Syncs the service's internal tracking to the native restored active model
67 + /// spec so that [createChat] can lazy-load the model on first use after a
68 + /// restart. Sets both [_currentModelId] and [_currentBackend] so the
69 + /// lazy-load path in [createChat] can actually trigger.
70 + void syncFromRestoredSpec(PreferredBackend backend) {
71 + final specName = restoredActiveModelName;
72 + if (specName == null) return;
73 +
74 + final model = OnDeviceModel.curatedModels.firstWhere(
75 + (m) => m.fileName == specName,
76 + orElse: () => throw StateError('No curated model for $specName'),
77 + );
78 +
79 + _currentModelId = model.id;
80 + _currentBackend = backend;
81 + }
82 +
83 static Future<void> initialize({String? huggingFaceToken}) async {
84 await FlutterGemma.initialize(
85 inferenceEngines: const [LiteRtLmEngine()],
@@ -34,17 +96,14 @@ class OnDeviceGemmaService {
96 Log.info('Installing model ${model.id} from ${model.huggingFaceUrl}');
97
98 await FlutterGemma.installModel(
37 - modelType: model.flutterGemmaModelType,
38 - fileType: ModelFileType.litertlm,
39 - )
40 - .fromNetwork(
41 - model.huggingFaceUrl,
42 - token: token,
43 - foreground: true,
99 + modelType: model.flutterGemmaModelType,
100 + fileType: ModelFileType.litertlm,
101 )
102 + .fromNetwork(model.huggingFaceUrl, token: token, foreground: true)
103 .withProgress((progress) {
46 - onProgress?.call(progress);
47 - }).install();
104 + onProgress?.call(progress);
105 + })
106 + .install();
107
108 Log.info('Model ${model.id} installed successfully');
109 }
@@ -138,19 +197,88 @@ class OnDeviceGemmaService {
197 Log.info('Model $modelId loaded successfully');
198 }
199
141 - Future<InferenceChat?> createChat({
200 + @override
201 + Future<OnDeviceInferenceSession> createChat({
202 String? systemInstruction,
203 List<Tool> tools = const [],
204 }) async {
145 - if (_model == null) {
205 + await _ensureModelLoaded();
206 +
207 + try {
208 + return await _openChat(
209 + systemInstruction: systemInstruction,
210 + tools: tools,
211 + );
212 + } catch (e) {
213 + if (!_isClosedClientError(e)) rethrow;
214 +
215 + Log.warning(
216 + 'Gemma FFI model closed while opening an independent chat ($e). '
217 + 'Reloading the model once.',
218 + );
219 + await _recoverClosedModel();
220 + return _openChat(systemInstruction: systemInstruction, tools: tools);
221 + }
222 + }
223 +
224 + Future<void> _ensureModelLoaded() async {
225 + if (_model != null) return;
226 + if (_currentModelId == null || _currentBackend == null) {
227 throw StateError('Model not loaded. Call loadModel first.');
228 }
229 + await loadModel(_currentModelId!, _currentBackend!);
230 + }
231
149 - return _model!.createChat(
232 + Future<OnDeviceInferenceSession> _openChat({
233 + required String? systemInstruction,
234 + required List<Tool> tools,
235 + }) async {
236 + final chat = await _model!.openChat(
237 systemInstruction: systemInstruction,
238 tools: tools,
239 supportsFunctionCalls: tools.isNotEmpty,
240 );
241 + return _GemmaInferenceSession(chat);
242 + }
243 +
244 + bool _isClosedClientError(Object error) {
245 + final message = error.toString().toLowerCase();
246 + return message.contains('client shut down') ||
247 + message.contains('model is closed') ||
248 + message.contains('model was closed');
249 + }
250 +
251 + Future<void> _recoverClosedModel() {
252 + final existing = _recoveryFuture;
253 + if (existing != null) return existing;
254 +
255 + final modelId = _currentModelId;
256 + final backend = _currentBackend;
257 + if (modelId == null || backend == null) {
258 + return Future.error(
259 + StateError('Cannot recover on-device model without a selected model'),
260 + );
261 + }
262 +
263 + final recovery = () async {
264 + final staleModel = _model;
265 + if (staleModel != null) {
266 + try {
267 + await staleModel.close();
268 + } catch (error) {
269 + Log.warning('Error awaiting stale Gemma model shutdown: $error');
270 + }
271 + }
272 + _model = null;
273 + await loadModel(modelId, backend);
274 + }();
275 +
276 + _recoveryFuture = recovery;
277 + return recovery.whenComplete(() {
278 + if (identical(_recoveryFuture, recovery)) {
279 + _recoveryFuture = null;
280 + }
281 + });
282 }
283
284 Future<void> unloadModel() async {
@@ -187,8 +315,9 @@ class OnDeviceGemmaService {
315 final modelId = fileName.replaceAll('.litertlm', '');
316
317 // Check if already registered in flutter_gemma
190 - final alreadyInstalled =
191 - await FlutterGemma.isModelInstalled(fileName);
318 + final alreadyInstalled = await FlutterGemma.isModelInstalled(
319 + fileName,
320 + );
321 if (alreadyInstalled) continue;
322
323 // Register the existing file with flutter_gemma
lib/features/on_device/data/on_device_llama_service.dart
+1
@@ -202,6 +202,7 @@ class OnDeviceLlamaService {
202 }
203
204 Future<void> unloadModel() async {
205 + cancelGeneration();
206 final engine = _engine;
207 _engine = null;
208 _session = null;
lib/features/on_device/providers/on_device_providers.dart
+48
@@ -16,6 +16,7 @@ import '../data/models/on_device_model.dart';
16 import '../data/notification_permission_service.dart';
17 import '../data/on_device_gemma_service.dart';
18 import '../data/on_device_llama_service.dart';
19 +import '../../servers/providers/server_providers.dart';
20
21 final notificationPermissionServiceProvider =
22 Provider<NotificationPermissionService>((ref) {
@@ -188,11 +189,46 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
189 }
190
191 class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
192 + bool _hasScheduledRestore = false;
193 +
194 @override
195 OnDeviceEngineState build() {
196 + if (!_hasScheduledRestore) {
197 + _hasScheduledRestore = true;
198 + Future.microtask(() => _restoreActiveModel());
199 + }
200 return const OnDeviceEngineState();
201 }
202
203 + /// After an app restart, the native model manager restores the last active
204 + /// inference model spec (logged as "[ModelManager] restored active inference
205 + /// model: ..."). The spec alone does NOT mean the model is loaded in memory,
206 + /// so we only sync the service's tracking (enabling lazy-load on first chat)
207 + /// without flipping the engine state to `loaded`. This keeps the model
208 + /// manager and model picker from showing a not-yet-loaded model as active.
209 + Future<void> _restoreActiveModel() async {
210 + if (!ref.mounted) return;
211 +
212 + final specName = _gemmaService.restoredActiveModelName;
213 + if (specName == null) return;
214 +
215 + final models = ref.read(onDeviceModelsProvider);
216 + final model = models.where((m) => m.fileName == specName).firstOrNull;
217 + if (model == null) return;
218 +
219 + final settings = ref.read(settingsProvider);
220 + final backend = model.isCpuOnly
221 + ? PreferredBackend.cpu
222 + : settings.preferredBackend;
223 +
224 + // Sync the service so createChat can lazy-load on first use.
225 + _gemmaService.syncFromRestoredSpec(backend);
226 +
227 + Log.info(
228 + 'Restored active inference model spec: ${model.id} (${model.fileName})',
229 + );
230 + }
231 +
232 OnDeviceGemmaService get _gemmaService =>
233 ref.read(onDeviceGemmaServiceProvider);
234 OnDeviceLlamaService get _llamaService =>
@@ -267,6 +303,18 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
303 backend: effectiveBackend,
304 );
305
306 + final activeServer = ref.read(activeServerProvider);
307 + if (activeServer?.type != ServerType.onDevice) {
308 + final servers = await ref.read(serversProvider.future);
309 + final onDeviceServer =
310 + servers.where((s) => s.type == ServerType.onDevice).firstOrNull;
311 + if (onDeviceServer != null) {
312 + ref
313 + .read(activeServerIdProvider.notifier)
314 + .setActiveServer(onDeviceServer);
315 + }
316 + }
317 +
318 Log.info(
319 'Model $modelId loaded successfully with ${effectiveBackend.name}',
320 );
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+1 -1
@@ -194,7 +194,7 @@ class _OnboardingModelDownloadScreenState
194
195 await ref.read(serversProvider.notifier).addServer(server);
196 await ref.read(serversProvider.notifier).setDefault(server.id);
197 - ref.read(activeServerProvider.notifier).setActiveServer(server);
197 + ref.read(activeServerIdProvider.notifier).setActiveServer(server);
198
199 if (mounted) {
200 context.push(AppRoutes.onboardingTheme);
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+1 -1
@@ -197,7 +197,7 @@ class _OnboardingServerSetupScreenState
197 await ref.read(serversProvider.notifier).addServer(server);
198 await ref.read(serversProvider.notifier).setDefault(server.id);
199 invalidateAvailableModelsCache(server.id);
200 - ref.read(activeServerProvider.notifier).setActiveServer(server);
200 + ref.read(activeServerIdProvider.notifier).setActiveServer(server);
201
202 if (mounted) {
203 context.push(AppRoutes.onboardingTheme);
lib/features/servers/providers/server_providers.dart
+55 -52
@@ -28,12 +28,67 @@ final serversProvider = AsyncNotifierProvider<ServersNotifier, List<Server>>(
28 },
29 );
30
31 +final activeServerIdProvider =
32 + NotifierProvider<ActiveServerIdNotifier, String?>(() {
33 + return ActiveServerIdNotifier();
34 + });
35 +
36 +class ActiveServerIdNotifier extends Notifier<String?> {
37 + static const _key = 'defaultServerId';
38 +
39 + @override
40 + String? build() {
41 + final prefs = ref.watch(sharedPreferencesProvider);
42 + return prefs.getString(_key);
43 + }
44 +
45 + void setActiveServerId(String? id) {
46 + state = id;
47 + final prefs = ref.read(sharedPreferencesProvider);
48 + if (id != null) {
49 + prefs.setString(_key, id);
50 + } else {
51 + prefs.remove(_key);
52 + }
53 + }
54 +
55 + void setActiveServer(Server? server) {
56 + setActiveServerId(server?.id);
57 + }
58 +}
59 +
60 final activeServerProvider = NotifierProvider<ActiveServerNotifier, Server?>(
61 () {
62 return ActiveServerNotifier();
63 },
64 );
65
66 +class ActiveServerNotifier extends Notifier<Server?> {
67 + @override
68 + Server? build() {
69 + final activeId = ref.watch(activeServerIdProvider);
70 + final servers = ref.watch(serversProvider).value ?? [];
71 + return _resolveServer(servers, activeId);
72 + }
73 +
74 + Server? _resolveServer(List<Server> servers, String? defaultServerId) {
75 + if (servers.isEmpty) return null;
76 +
77 + if (defaultServerId != null && defaultServerId.isNotEmpty) {
78 + final matching = servers.where((s) => s.id == defaultServerId);
79 + if (matching.isNotEmpty) return matching.first;
80 + }
81 +
82 + final defaults = servers.where((s) => s.isDefault);
83 + return defaults.isNotEmpty ? defaults.first : servers.first;
84 + }
85 +
86 + void setActiveServer(Server? server) {
87 + ref.read(activeServerIdProvider.notifier).setActiveServer(server);
88 + state = server;
89 + }
90 +}
91 +
92 final connectionStatusProvider =
93 NotifierProvider<ConnectionStatusNotifier, ConnectionStatus>(() {
94 return ConnectionStatusNotifier();
@@ -265,58 +320,6 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
320 }
321 }
322
268 -class ActiveServerNotifier extends Notifier<Server?> {
269 - @override
270 - Server? build() {
271 - // Use ref.listen instead of ref.watch to avoid triggering invalidateSelf
272 - // during a rebuild cycle, which causes a Riverpod assertion error on
273 - // pausedActiveSubscriptionCount.
274 - ref.listen<AsyncValue<List<Server>>>(serversProvider, (prev, next) {
275 - if (next.hasValue) {
276 - Future.microtask(() => _updateFromServers(next.value!));
277 - }
278 - });
279 -
280 - final prefs = ref.read(sharedPreferencesProvider);
281 - final serversAsync = ref.read(serversProvider);
282 - final servers = serversAsync.value ?? [];
283 - return _resolveServer(servers, prefs.getString('defaultServerId'));
284 - }
285 -
286 - Server? _resolveServer(List<Server> servers, String? defaultServerId) {
287 - if (servers.isEmpty) return null;
288 -
289 - if (defaultServerId != null && defaultServerId.isNotEmpty) {
290 - final matching = servers.where((s) => s.id == defaultServerId);
291 - if (matching.isNotEmpty) return matching.first;
292 - }
293 -
294 - final defaults = servers.where((s) => s.isDefault);
295 - return defaults.isNotEmpty ? defaults.first : servers.first;
296 - }
297 -
298 - void _updateFromServers(List<Server> servers) {
299 - final prefs = ref.read(sharedPreferencesProvider);
300 - final currentId = state?.id;
301 - final resolved = _resolveServer(
302 - servers,
303 - currentId ?? prefs.getString('defaultServerId'),
304 - );
305 - if (resolved?.id != state?.id || resolved?.status != state?.status) {
306 - state = resolved;
307 - }
308 - }
309 -
310 - void setActiveServer(Server? server) {
311 - final prefs = ref.read(sharedPreferencesProvider);
312 - state = server;
313 - if (server != null) {
314 - prefs.setString('defaultServerId', server.id);
315 - } else {
316 - prefs.remove('defaultServerId');
317 - }
318 - }
319 -}
323
324 class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
325 @override
lib/features/servers/views/components/server_picker_sheet.dart
+1 -1
@@ -111,7 +111,7 @@ void showServerPickerSheet(BuildContext context, WidgetRef ref) {
111 ? null
112 : () {
113 ref
114 - .read(activeServerProvider.notifier)
114 + .read(activeServerIdProvider.notifier)
115 .setActiveServer(server);
116 Navigator.pop(context);
117 },
lib/features/servers/views/server_list_screen.dart
+1 -1
@@ -110,7 +110,7 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
110 isActive: activeServer?.id == server.id,
111 onTap: () {
112 ref
113 - .read(activeServerProvider.notifier)
113 + .read(activeServerIdProvider.notifier)
114 .setActiveServer(server);
115 ScaffoldMessenger.of(context).showSnackBar(
116 SnackBar(
lib/features/sidebar/components/active_server_indicator.dart
+1 -1
@@ -210,7 +210,7 @@ class ActiveServerIndicator extends ConsumerWidget {
210 : () {
211 ref
212 .read(
213 - activeServerProvider.notifier,
213 + activeServerIdProvider.notifier,
214 )
215 .setActiveServer(server);
216 Navigator.pop(context);
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.6.5+70
19 +version: 1.6.7+72
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"
test/features/chat/smart_reply_service_single_flight_test.dart new
+94
@@ -0,0 +1,94 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/features/chat/data/chat_service.dart';
6 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
7 +import 'package:localmind/features/chat/data/models/mcp_integration.dart';
8 +import 'package:localmind/features/chat/data/models/message.dart';
9 +import 'package:localmind/features/chat/data/smart_reply_service.dart';
10 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
11 +import 'package:localmind/features/servers/data/models/server.dart';
12 +
13 +void main() {
14 + test(
15 + 'shares an in-flight suggestion request for the same assistant message',
16 + () async {
17 + final chatService = _ControlledChatService();
18 + final service = SmartReplyService();
19 + final messages = [
20 + Message(
21 + id: 'assistant-1',
22 + conversationId: 'conversation-1',
23 + role: MessageRole.assistant,
24 + content: 'How can I help?',
25 + createdAt: DateTime.utc(2026, 8, 9),
26 + ),
27 + ];
28 +
29 + final first = service.suggestRepliesWithLLM(
30 + chatService: chatService,
31 + server: _server(),
32 + modelId: 'qwen3-0.6b',
33 + messages: messages,
34 + params: ChatParameters.defaults(),
35 + );
36 + final second = service.suggestRepliesWithLLM(
37 + chatService: chatService,
38 + server: _server(),
39 + modelId: 'qwen3-0.6b',
40 + messages: messages,
41 + params: ChatParameters.defaults(),
42 + );
43 +
44 + expect(chatService.sendCount, 1);
45 + chatService.controller.add(
46 + const ChatResponse(
47 + type: ChatResponseType.message,
48 + content: '["Yes", "No", "Tell me more"]',
49 + ),
50 + );
51 + chatService.controller.add(
52 + const ChatResponse(type: ChatResponseType.done),
53 + );
54 +
55 + final results = await Future.wait([first, second]);
56 + expect(results[0], ['Yes', 'No', 'Tell me more']);
57 + expect(results[1], results[0]);
58 + expect(chatService.sendCount, 1);
59 + },
60 + );
61 +}
62 +
63 +class _ControlledChatService implements ChatService {
64 + final StreamController<ChatResponse> controller = StreamController();
65 + int sendCount = 0;
66 +
67 + @override
68 + Stream<ChatResponse> sendMessage({
69 + required Server server,
70 + required String modelId,
71 + required List<Message> messages,
72 + required ChatParameters params,
73 + List<McpIntegration>? integrations,
74 + List<ToolDefinition>? tools,
75 + bool continueGeneration = false,
76 + }) {
77 + sendCount++;
78 + return controller.stream;
79 + }
80 +
81 + @override
82 + void cancelStream() {}
83 +}
84 +
85 +Server _server() => Server(
86 + id: 'on-device',
87 + name: 'On-Device',
88 + type: ServerType.onDevice,
89 + host: '',
90 + port: 0,
91 + createdAt: DateTime.utc(2026, 8, 9),
92 + lastConnectedAt: DateTime.utc(2026, 8, 9),
93 + status: ConnectionStatus.connected,
94 +);
test/features/on_device/on_device_chat_service_lifecycle_test.dart new
+258
@@ -0,0 +1,258 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_gemma/flutter_gemma.dart' as gemma;
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/services/crash_report_service.dart';
7 +import 'package:localmind/features/chat/data/chat_service.dart';
8 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
9 +import 'package:localmind/features/chat/data/models/message.dart';
10 +import 'package:localmind/features/on_device/data/on_device_chat_service.dart';
11 +import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
12 +import 'package:localmind/features/servers/data/models/server.dart';
13 +
14 +void main() {
15 + TestWidgetsFlutterBinding.ensureInitialized();
16 +
17 + late _FakeInferenceService inferenceService;
18 + late OnDeviceChatService chatService;
19 +
20 + setUp(() {
21 + CrashReportService.instance.resetForTesting();
22 + inferenceService = _FakeInferenceService();
23 + chatService = OnDeviceChatService(inferenceService);
24 + });
25 +
26 + tearDown(() {
27 + chatService.dispose();
28 + CrashReportService.instance.resetForTesting();
29 + });
30 +
31 + test(
32 + 'overlapping requests use independent sessions and both complete',
33 + () async {
34 + final firstResponses = <ChatResponse>[];
35 + final secondResponses = <ChatResponse>[];
36 +
37 + final firstDone = chatService
38 + .sendMessage(
39 + server: _server(),
40 + modelId: 'qwen3-0.6b',
41 + messages: [_message('first')],
42 + params: ChatParameters.defaults(),
43 + )
44 + .listen(firstResponses.add)
45 + .asFuture<void>();
46 + final secondDone = chatService
47 + .sendMessage(
48 + server: _server(),
49 + modelId: 'qwen3-0.6b',
50 + messages: [_message('second')],
51 + params: ChatParameters.defaults(),
52 + )
53 + .listen(secondResponses.add)
54 + .asFuture<void>();
55 +
56 + await _waitFor(() => inferenceService.sessions.length == 2);
57 + final first = inferenceService.sessions[0];
58 + final second = inferenceService.sessions[1];
59 +
60 + first.responses.add(const gemma.TextResponse('one'));
61 + second.responses.add(const gemma.TextResponse('two'));
62 + await first.responses.close();
63 + await second.responses.close();
64 + await Future.wait([firstDone, secondDone]);
65 +
66 + expect(inferenceService.createCount, 2);
67 + expect(first.closeCount, 1);
68 + expect(second.closeCount, 1);
69 + expect(first.stopCount, 0);
70 + expect(second.stopCount, 0);
71 + expect(
72 + firstResponses.where((response) => response.content == 'one'),
73 + hasLength(1),
74 + );
75 + expect(
76 + secondResponses.where((response) => response.content == 'two'),
77 + hasLength(1),
78 + );
79 + },
80 + );
81 +
82 + test('cancelling one subscription only closes its session', () async {
83 + final firstSubscription = chatService
84 + .sendMessage(
85 + server: _server(),
86 + modelId: 'qwen3-0.6b',
87 + messages: [_message('first')],
88 + params: ChatParameters.defaults(),
89 + )
90 + .listen((_) {});
91 + final secondResponses = <ChatResponse>[];
92 + final secondDone = chatService
93 + .sendMessage(
94 + server: _server(),
95 + modelId: 'qwen3-0.6b',
96 + messages: [_message('second')],
97 + params: ChatParameters.defaults(),
98 + )
99 + .listen(secondResponses.add)
100 + .asFuture<void>();
101 +
102 + await _waitFor(
103 + () =>
104 + inferenceService.sessions.length == 2 &&
105 + inferenceService.sessions.every((session) => session.didGenerate),
106 + );
107 + await firstSubscription.cancel();
108 + await _waitFor(() => inferenceService.sessions.first.closeCount == 1);
109 +
110 + expect(inferenceService.sessions.first.stopCount, 1);
111 + expect(inferenceService.sessions.last.closeCount, 0);
112 +
113 + inferenceService.sessions.last.responses.add(
114 + const gemma.TextResponse('still running'),
115 + );
116 + await inferenceService.sessions.last.responses.close();
117 + await secondDone;
118 +
119 + expect(
120 + secondResponses.any((response) => response.content == 'still running'),
121 + isTrue,
122 + );
123 + });
124 +
125 + test('handled inference errors do not activate crash reporting', () async {
126 + final responses = <ChatResponse>[];
127 + final done = chatService
128 + .sendMessage(
129 + server: _server(),
130 + modelId: 'qwen3-0.6b',
131 + messages: [_message('fail')],
132 + params: ChatParameters.defaults(),
133 + )
134 + .listen(responses.add)
135 + .asFuture<void>();
136 +
137 + await _waitFor(() => inferenceService.sessions.isNotEmpty);
138 + inferenceService.sessions.single.responses.addError(
139 + StateError('expected inference failure'),
140 + StackTrace.current,
141 + );
142 + await done;
143 +
144 + expect(
145 + responses.any((response) => response.type == ChatResponseType.error),
146 + isTrue,
147 + );
148 + expect(CrashReportService.instance.currentCrash.value, isNull);
149 + });
150 +
151 + test('cancelStream stops and closes every active request once', () async {
152 + final subscriptions = List.generate(
153 + 2,
154 + (index) => chatService
155 + .sendMessage(
156 + server: _server(),
157 + modelId: 'qwen3-0.6b',
158 + messages: [_message('request-$index')],
159 + params: ChatParameters.defaults(),
160 + )
161 + .listen((_) {}),
162 + );
163 +
164 + await _waitFor(
165 + () =>
166 + inferenceService.sessions.length == 2 &&
167 + inferenceService.sessions.every((session) => session.didGenerate),
168 + );
169 + chatService.cancelStream();
170 + await _waitFor(
171 + () =>
172 + inferenceService.sessions.every((session) => session.closeCount == 1),
173 + );
174 +
175 + for (final session in inferenceService.sessions) {
176 + expect(session.stopCount, 1);
177 + expect(session.closeCount, 1);
178 + }
179 + for (final subscription in subscriptions) {
180 + await subscription.cancel();
181 + }
182 + });
183 +}
184 +
185 +class _FakeInferenceService implements OnDeviceInferenceService {
186 + final List<_FakeInferenceSession> sessions = [];
187 + int createCount = 0;
188 +
189 + @override
190 + bool get isLoaded => true;
191 +
192 + @override
193 + Future<OnDeviceInferenceSession> createChat({
194 + String? systemInstruction,
195 + List<gemma.Tool> tools = const [],
196 + }) async {
197 + createCount++;
198 + final session = _FakeInferenceSession();
199 + sessions.add(session);
200 + return session;
201 + }
202 +}
203 +
204 +class _FakeInferenceSession implements OnDeviceInferenceSession {
205 + final StreamController<gemma.ModelResponse> responses = StreamController();
206 + final List<gemma.Message> messages = [];
207 + int stopCount = 0;
208 + int closeCount = 0;
209 + bool didGenerate = false;
210 +
211 + @override
212 + Future<void> addQueryChunk(gemma.Message message) async {
213 + messages.add(message);
214 + }
215 +
216 + @override
217 + Stream<gemma.ModelResponse> generateChatResponseAsync() {
218 + didGenerate = true;
219 + return responses.stream;
220 + }
221 +
222 + @override
223 + Future<void> stopGeneration() async {
224 + stopCount++;
225 + }
226 +
227 + @override
228 + Future<void> close() async {
229 + closeCount++;
230 + }
231 +}
232 +
233 +Future<void> _waitFor(bool Function() condition) async {
234 + for (var attempt = 0; attempt < 100; attempt++) {
235 + if (condition()) return;
236 + await Future<void>.delayed(const Duration(milliseconds: 1));
237 + }
238 + fail('Condition was not met');
239 +}
240 +
241 +Message _message(String content) => Message(
242 + id: content,
243 + conversationId: 'conversation',
244 + role: MessageRole.user,
245 + content: content,
246 + createdAt: DateTime.utc(2026, 8, 9),
247 +);
248 +
249 +Server _server() => Server(
250 + id: 'on-device',
251 + name: 'On-Device',
252 + type: ServerType.onDevice,
253 + host: '',
254 + port: 0,
255 + createdAt: DateTime.utc(2026, 8, 9),
256 + lastConnectedAt: DateTime.utc(2026, 8, 9),
257 + status: ConnectionStatus.connected,
258 +);