Refactor chat streams, search, and server parsing
Streamline stream lifecycle handling in ChatNotifier: add _detachStreamSubscription that awaits cancellation, make abort/start/cancel flows await detachment, null subscriptions early and ensure callbacks/timers/approvals are cleared to avoid races. Simplify DataBackupService ZIP return by removing redundant null check. Optimize MessageSearchService by collecting conversation IDs during message scan and batch-loading titles via a new _loadConversationTitles helper. Simplify ServerApiService parsing: lmStudio uses OpenAI-compatible parser, all other types use the Ollama parser (replacing the prior switch with empty branches).
onlypuppy7 committed
Jul 1, 2026 at 16:07 UTC
da1929e8a20094ae429166e41296292cdd1766ce
4 files changed
+54
-40
lib/core/services/data_backup_service.dart
+1
-5
@@ -281,11 +281,7 @@ class DataBackupService {
281
exportSettingsAsJson(settingsJson, store: store, prefs: prefs),
282
),
283
);
284
- final encoded = ZipEncoder().encode(archive);
285
- if (encoded == null) {
286
- throw StateError('Failed to encode backup ZIP');
287
- }
288
- return encoded;
284
+ return ZipEncoder().encode(archive);
285
}
286
287
String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
lib/features/chat/providers/chat_notifier.dart
+17
-15
@@ -189,7 +189,10 @@ class ChatNotifier extends Notifier<ChatState> {
189
_saveService = MessageSaveService(db);
190
ref.onDispose(() {
191
_uiUpdateTimer?.cancel();
192
- _streamSubscription?.cancel();
192
+ _invalidateStreamCallbacks();
193
+ final subscription = _streamSubscription;
194
+ _streamSubscription = null;
195
+ subscription?.cancel();
196
_saveService?.dispose();
197
final pending = state.pendingToolApproval;
198
if (pending != null && !pending.completer.isCompleted) {
@@ -334,7 +337,7 @@ class ChatNotifier extends Notifier<ChatState> {
337
}
338
339
Future<void> startNewConversation() async {
337
- _abortStreamImmediately();
340
+ await _abortStreamImmediately();
341
342
_currentConversationId = null;
343
_ephemeralConversationId = null;
@@ -355,17 +358,20 @@ class ChatNotifier extends Notifier<ChatState> {
358
_streamGeneration++;
359
}
360
358
- void _abortStreamImmediately() {
361
+ Future<void> _detachStreamSubscription() async {
362
_invalidateStreamCallbacks();
360
- _uiUpdateTimer?.cancel();
361
- _uiUpdateTimer = null;
362
- _clearPendingApproval();
363
-
363
final subscription = _streamSubscription;
364
_streamSubscription = null;
365
if (subscription != null) {
367
- unawaited(subscription.cancel());
366
+ await subscription.cancel();
367
}
368
+ }
369
+
370
+ Future<void> _abortStreamImmediately() async {
371
+ await _detachStreamSubscription();
372
+ _uiUpdateTimer?.cancel();
373
+ _uiUpdateTimer = null;
374
+ _clearPendingApproval();
375
376
ref.read(chatServiceProvider)?.cancelStream();
377
ref.read(chatBackgroundServiceProvider).stop();
@@ -616,8 +622,7 @@ class ChatNotifier extends Notifier<ChatState> {
622
final messagesForApi = _buildMessagesForApi(selectedModel);
623
624
try {
619
- _streamSubscription?.cancel();
620
- _invalidateStreamCallbacks();
625
+ await _detachStreamSubscription();
626
_uiUpdateTimer?.cancel();
627
628
String reasoningContent = '';
@@ -1244,12 +1249,10 @@ class ChatNotifier extends Notifier<ChatState> {
1249
}
1250
1251
Future<void> cancelStream() async {
1247
- _invalidateStreamCallbacks();
1252
+ await _detachStreamSubscription();
1253
_uiUpdateTimer?.cancel();
1254
_uiUpdateTimer = null;
1255
_clearPendingApproval();
1251
- await _streamSubscription?.cancel();
1252
- _streamSubscription = null;
1256
ref.read(chatServiceProvider)?.cancelStream();
1257
ref.read(chatBackgroundServiceProvider).stop();
1258
@@ -1487,8 +1490,7 @@ class ChatNotifier extends Notifier<ChatState> {
1490
: _buildMessagesForApi(selectedModel);
1491
1492
try {
1490
- _streamSubscription?.cancel();
1491
- _invalidateStreamCallbacks();
1493
+ await _detachStreamSubscription();
1494
_uiUpdateTimer?.cancel();
1495
1496
String reasoningContent = '';
lib/features/conversations/data/message_search_service.dart
+33
-9
@@ -14,13 +14,8 @@ class MessageSearchService {
14
final normalizedQuery = query.trim().toLowerCase();
15
if (normalizedQuery.isEmpty) return const [];
16
17
- final conversations = db.conversationBox.getAll();
18
- final titleById = {
19
- for (final conversation in conversations)
20
- conversation.id: conversation.title,
21
- };
22
-
17
final hits = <MessageSearchHit>[];
18
+ final conversationIds = <String>{};
19
final messageQuery = db.messageBox
20
.query(
21
MessageEntity_.content.contains(
@@ -35,7 +30,6 @@ class MessageSearchService {
30
for (final entity in messages) {
31
if (!entity.content.toLowerCase().contains(normalizedQuery)) continue;
32
38
- final title = titleById[entity.conversationUid] ?? 'Chat';
33
final content = entity.content;
34
final index = content.toLowerCase().indexOf(normalizedQuery);
35
final start = index > 40 ? index - 40 : 0;
@@ -44,17 +38,47 @@ class MessageSearchService {
38
if (start > 0) snippet = '…$snippet';
39
if (end < content.length) snippet = '$snippet…';
40
41
+ conversationIds.add(entity.conversationUid);
42
hits.add(
43
MessageSearchHit(
44
messageId: entity.id,
45
conversationId: entity.conversationUid,
51
- conversationTitle: title,
46
+ conversationTitle: 'Chat',
47
snippet: snippet,
48
role: MessageRole.values[entity.roleIndex],
49
),
50
);
51
if (hits.length >= limit) break;
52
}
58
- return hits;
53
+
54
+ if (hits.isEmpty) return hits;
55
+
56
+ final titleById = _loadConversationTitles(db, conversationIds);
57
+ return hits
58
+ .map(
59
+ (hit) => MessageSearchHit(
60
+ messageId: hit.messageId,
61
+ conversationId: hit.conversationId,
62
+ conversationTitle: titleById[hit.conversationId] ?? 'Chat',
63
+ snippet: hit.snippet,
64
+ role: hit.role,
65
+ ),
66
+ )
67
+ .toList();
68
+ }
69
+
70
+ Map<String, String> _loadConversationTitles(
71
+ ObjectBoxStore db,
72
+ Set<String> conversationIds,
73
+ ) {
74
+ if (conversationIds.isEmpty) return const {};
75
+
76
+ final query = db.conversationBox
77
+ .query(ConversationEntity_.id.oneOf(conversationIds.toList(growable: false)))
78
+ .build();
79
+ final entities = query.find();
80
+ query.close();
81
+
82
+ return {for (final entity in entities) entity.id: entity.title};
83
}
84
}
lib/features/servers/data/server_api_service.dart
+3
-11
@@ -74,18 +74,10 @@ class ServerApiService {
74
options: Options(headers: buildServerAuthHeaders(server)),
75
);
76
77
- switch (server.type) {
78
- case ServerType.lmStudio:
79
- return _parseRunningOpenAICompatibleModels(response.data);
80
- case ServerType.ollama:
81
- return _parseRunningOllamaModels(response.data);
82
- case ServerType.openRouter:
83
- return {};
84
- case ServerType.onDevice:
85
- return {};
86
- case ServerType.openAICompatible:
87
- return {};
77
+ if (server.type == ServerType.lmStudio) {
78
+ return _parseRunningOpenAICompatibleModels(response.data);
79
}
80
+ return _parseRunningOllamaModels(response.data);
81
} catch (e) {
82
return {};
83
}