@tduncan / Cortana / commits / da1929e

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 }