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
+);