main
dart 2,988 lines 105 KB
Raw
1 import 'dart:async';
2 import 'dart:io';
3 import 'dart:math';
4
5 import 'package:flutter/foundation.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:localmind/core/logger/app_logger.dart';
8 import 'package:localmind/core/models/enums.dart';
9 import 'package:localmind/core/providers/app_providers.dart';
10 import 'package:localmind/core/providers/review_prompt_providers.dart';
11 import 'package:localmind/core/providers/service_providers.dart';
12 import 'package:localmind/core/providers/chat_background_service_provider.dart';
13 import 'package:localmind/core/providers/storage_providers.dart';
14 import 'package:localmind/core/services/app_haptics.dart';
15 import 'package:localmind/core/services/message_save_service.dart';
16 import 'package:localmind/core/storage/entities.dart';
17 import 'package:localmind/features/conversations/data/models/conversation.dart';
18 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
19 as conv;
20 import 'package:localmind/features/models/data/models/model_info.dart';
21 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
22 import 'package:localmind/features/personas/providers/personas_providers.dart';
23 import 'package:localmind/features/personas/utils/persona_prompt_utils.dart';
24 import 'package:localmind/features/servers/data/models/server.dart';
25 import 'package:localmind/features/servers/providers/server_providers.dart';
26 import 'package:localmind/objectbox.g.dart';
27 import '../data/chat_service.dart';
28 import '../data/models/chat_parameters.dart';
29 import '../data/models/message.dart' hide ToolCallData;
30 import '../data/models/message.dart' as msg_model show ToolCallData;
31 import '../data/title_generation_service.dart';
32 import '../data/tools/tool_definition.dart';
33 import '../data/tools/tool_event.dart';
34 import '../data/tools/tool_execution_loop.dart';
35 import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
36 import '../utils/attachment_helpers.dart';
37 import 'chat_mcp_providers.dart';
38 import 'chat_origin_provider.dart';
39 import 'chat_params_providers.dart';
40 import 'chat_reasoning_providers.dart';
41 import 'chat_service_providers.dart';
42 import 'generation_session.dart';
43 import 'message_selection_provider.dart';
44 import 'model_selection_providers.dart';
45 import 'tooling_providers.dart';
46 import '../utils/message_variants.dart';
47 import '../../tts/providers/tts_providers.dart';
48 import '../../stt/providers/stt_providers.dart' as stt;
49 import '../../voice_mode/providers/voice_mode_provider.dart';
50
51 class PendingToolApproval {
52 final ParsedToolCall toolCall;
53 final Completer<bool> completer;
54
55 PendingToolApproval({required this.toolCall, required this.completer});
56 }
57
58 bool shouldIncludeMessageInChatContext(Message message) {
59 if (message.role == MessageRole.assistant &&
60 message.status == MessageStatus.error &&
61 message.content.trim().isEmpty) {
62 return false;
63 }
64 return true;
65 }
66
67 class ChatState {
68 final List<Message> messages;
69 final List<Message> allMessages;
70 final bool isStreaming;
71 final bool isLoading;
72 final String? errorMessage;
73 final Message? streamingMessage;
74 final PendingToolApproval? pendingToolApproval;
75 final bool isTemporary;
76
77 const ChatState({
78 this.messages = const [],
79 this.allMessages = const [],
80 this.isStreaming = false,
81 this.isLoading = false,
82 this.errorMessage,
83 this.streamingMessage,
84 this.pendingToolApproval,
85 this.isTemporary = false,
86 });
87
88 ChatState copyWith({
89 List<Message>? messages,
90 List<Message>? allMessages,
91 bool? isStreaming,
92 bool? isLoading,
93 String? errorMessage,
94 Message? streamingMessage,
95 PendingToolApproval? pendingToolApproval,
96 bool? isTemporary,
97 bool clearError = false,
98 bool clearStreaming = false,
99 bool clearPendingApproval = false,
100 }) {
101 return ChatState(
102 messages: messages ?? this.messages,
103 allMessages: allMessages ?? this.allMessages,
104 isStreaming: isStreaming ?? this.isStreaming,
105 isLoading: isLoading ?? this.isLoading,
106 errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
107 streamingMessage: clearStreaming
108 ? null
109 : (streamingMessage ?? this.streamingMessage),
110 pendingToolApproval: clearPendingApproval
111 ? null
112 : (pendingToolApproval ?? this.pendingToolApproval),
113 isTemporary: isTemporary ?? this.isTemporary,
114 );
115 }
116 }
117
118 final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
119 return ChatNotifier();
120 });
121
122 class ChatNotifier extends Notifier<ChatState> {
123 MessageSaveService? _saveService;
124 PendingToolApproval? _pendingToolApproval;
125 final Set<String> _titleGenerationsInFlight = <String>{};
126 final Map<String, Future<void>> _titleGenerationFutures =
127 <String, Future<void>>{};
128 bool _pendingTemporaryChat = false;
129 String? _currentConversationId;
130 String? _ephemeralConversationId;
131 bool _attemptedResume = false;
132 static const _lastActiveConversationKey = 'lastActiveConversationId';
133 bool _useFreshConversationSystemPrompt = false;
134 bool _isRegenerating = false;
135 String? _freshConversationSystemPrompt;
136
137 /// In-flight replies keyed by conversation id (insertion order = start
138 /// order). Remote replies run concurrently, one per conversation; at most
139 /// one on-device reply exists at a time.
140 final Map<String, GenerationSession> _sessions = {};
141 bool _backgroundServiceRunning = false;
142
143 String? get _activeConversationId =>
144 _currentConversationId ?? _ephemeralConversationId;
145
146 bool get _isInMemoryChat => state.isTemporary;
147
148 /// Reply generating for the open chat, if any.
149 GenerationSession? get _activeSession {
150 final id = _activeConversationId;
151 return id == null ? null : _sessions[id];
152 }
153
154 /// Conversations other than the open chat whose reply is still generating.
155 Set<String> get generatingElsewhereIds => {
156 for (final id in _sessions.keys)
157 if (id != _activeConversationId) id,
158 };
159
160 /// Registers the reply that is about to stream into [conversationId]. A
161 /// previous session for the same chat (a tool-call follow-up) is replaced.
162 GenerationSession _beginSession(
163 String conversationId,
164 Server server,
165 ChatService chatService,
166 ) {
167 final previous = _sessions[conversationId];
168 final session = GenerationSession(
169 conversationId: conversationId,
170 server: server,
171 chatService: chatService,
172 persisted: previous?.persisted ?? !_isInMemoryChat,
173 );
174 _sessions[conversationId] = session;
175 if (previous != null) unawaited(previous.detach());
176 _publishGenerations();
177 return session;
178 }
179
180 /// Marks [session] finished. A session that was already replaced by a newer
181 /// one for the same chat is left alone.
182 void _endSession(GenerationSession session) {
183 session.cancelUiTimer();
184 if (identical(_sessions[session.conversationId], session)) {
185 _sessions.remove(session.conversationId);
186 }
187 _publishGenerations();
188 }
189
190 /// Pushes the running replies to the UI providers and keeps the Android
191 /// foreground service / wakelock alive until the last reply finishes.
192 void _publishGenerations() {
193 if (!ref.mounted) return;
194 ref.read(activeGenerationsProvider.notifier).set({
195 for (final s in _sessions.values)
196 s.conversationId: ActiveGeneration(
197 conversationId: s.conversationId,
198 isOnDevice: s.isOnDevice,
199 ),
200 });
201 final activeId = _activeConversationId;
202 ref
203 .read(isStreamingProvider.notifier)
204 .setStreaming(activeId != null && _sessions.containsKey(activeId));
205
206 final shouldRun = _sessions.isNotEmpty;
207 if (shouldRun != _backgroundServiceRunning) {
208 _backgroundServiceRunning = shouldRun;
209 final service = ref.read(chatBackgroundServiceProvider);
210 unawaited(shouldRun ? service.start() : service.stop());
211 }
212 }
213
214 /// Whether the open chat's reply may keep streaming after the user leaves
215 /// it. Temporary chats aren't persisted, tool approvals need the chat on
216 /// screen, and voice mode drives its own turn-taking.
217 bool _canContinueInBackground() {
218 if (!state.isStreaming || _isInMemoryChat) return false;
219 if (_activeSession == null) return false;
220 if (_pendingToolApproval != null) return false;
221 if (ref.read(chatMcpConfigProvider).enabled) return false;
222 return ref.read(voiceModeProvider).phase == VoiceModePhase.idle;
223 }
224
225 /// The on-device engine serves one reply at a time. Returns true (and
226 /// explains why) when the active server is on-device and another chat's
227 /// on-device reply is still running. Remote servers are never blocked.
228 bool _blockIfOnDeviceBusy() {
229 final server = ref.read(activeChatTargetProvider).server;
230 if (server == null || !server.isOnDevice) return false;
231 final busy = _sessions.values.any(
232 (s) => s.isOnDevice && s.conversationId != _activeConversationId,
233 );
234 if (!busy) return false;
235 state = state.copyWith(errorMessage: onDeviceBusyMessage);
236 return true;
237 }
238
239 /// Cancels the reply generating for [conversationId], if any. Used before
240 /// deleting a conversation that may be streaming in the background.
241 Future<void> cancelGenerationFor(String conversationId) async {
242 final session = _sessions[conversationId];
243 if (session == null) return;
244 await session.detach();
245 if (!ref.mounted) return;
246 session.chatService.cancelStream();
247 session.latestMessage = null;
248 _endSession(session);
249 if (_activeConversationId == conversationId) {
250 state = state.copyWith(isStreaming: false, clearStreaming: true);
251 }
252 }
253
254 /// Cancels every running reply (for example before wiping all chats).
255 Future<void> cancelAllGenerations() async {
256 for (final id in _sessions.keys.toList()) {
257 await cancelGenerationFor(id);
258 }
259 }
260
261 Message _finalizeStreamMessage(
262 Message msg, {
263 String? stopReason,
264 GenerationSession? session,
265 }) {
266 final effectiveStats = session?.stats;
267 final effectiveStart = session?.startTime;
268 final effectiveFirstToken = session?.firstTokenTime;
269 final now = DateTime.now();
270
271 int? ttftMs;
272 if (effectiveFirstToken != null && effectiveStart != null) {
273 ttftMs = effectiveFirstToken.difference(effectiveStart).inMilliseconds;
274 } else if (effectiveStats?.timeToFirstTokenSeconds != null) {
275 ttftMs = (effectiveStats!.timeToFirstTokenSeconds! * 1000).round();
276 }
277
278 int? genMs;
279 if (effectiveStart != null) {
280 genMs = now.difference(effectiveStart).inMilliseconds;
281 }
282
283 double? tps = effectiveStats?.tokensPerSecond;
284 final outputTokens = effectiveStats?.totalOutputTokens;
285 if (tps == null && outputTokens != null && genMs != null && genMs > 0) {
286 tps = outputTokens / (genMs / 1000);
287 }
288
289 return msg.copyWith(
290 tokenCount: outputTokens ?? msg.tokenCount,
291 inputTokenCount: effectiveStats?.inputTokens ?? msg.inputTokenCount,
292 tokensPerSecond: tps ?? msg.tokensPerSecond,
293 generationTimeMs: genMs ?? msg.generationTimeMs,
294 ttftMs: ttftMs ?? msg.ttftMs,
295 stopReason: stopReason ?? msg.stopReason,
296 );
297 }
298
299 /// Updates the conversation's total token count from the most recent
300 /// assistant message's own end-of-stream stats (`inputTokenCount` +
301 /// `tokenCount`, populated from the server's real usage/stats payload) —
302 /// this is exactly the size of the context window the model saw on its
303 /// last turn, so it doubles as an accurate "context used" figure without
304 /// ever issuing an extra request just to count tokens.
305 void _recomputeConversationTotal(
306 String conversationId, {
307 List<Message>? messages,
308 }) {
309 final timeline = messages ?? state.messages;
310 final lastWithStats = timeline.reversed
311 .where(
312 (m) =>
313 m.role == MessageRole.assistant &&
314 (m.inputTokenCount != null || m.tokenCount != null),
315 )
316 .firstOrNull;
317 final total = lastWithStats == null
318 ? 0
319 : (lastWithStats.inputTokenCount ?? 0) +
320 (lastWithStats.tokenCount ?? 0);
321 unawaited(
322 ref
323 .read(conv.conversationsProvider.notifier)
324 .updateTokenCount(conversationId, total),
325 );
326 }
327
328 /// Syncs the history-list preview/message-count/char-count/token-total
329 /// for [conversationId] after a generation finishes. Generation runs in
330 /// the background even after the user switches to a different
331 /// conversation, so this must never assume `state.messages` belongs to
332 /// [conversationId] — when it isn't the conversation currently on screen,
333 /// the active timeline is re-read from the database instead. Without
334 /// this, a background reply could overwrite the *foreground* conversation's
335 /// history preview with its own content.
336 Future<void> _syncConversationStatsAfterGeneration(
337 String conversationId,
338 Message finalMessage, {
339 required bool isCurrentContext,
340 }) async {
341 if (!ref.mounted) return;
342 final timeline = isCurrentContext
343 ? state.messages
344 : MessageVariants.resolveActiveTimeline(
345 await loadConversationMessages(conversationId),
346 );
347
348 if (!ref.mounted) return;
349 final preview = finalMessage.content.length > 100
350 ? '${finalMessage.content.substring(0, 100)}...'
351 : finalMessage.content;
352 final totalChars = timeline.fold<int>(
353 0,
354 (sum, message) => sum + message.content.length,
355 );
356
357 await ref
358 .read(conv.conversationsProvider.notifier)
359 .syncConversationStats(
360 conversationId,
361 messageCount: timeline.length,
362 characterCount: totalChars,
363 preview: preview,
364 );
365 if (!ref.mounted) return;
366 _recomputeConversationTotal(conversationId, messages: timeline);
367
368 final lastUserMessage = timeline
369 .where((m) => m.role == MessageRole.user)
370 .firstOrNull;
371 if (lastUserMessage != null) {
372 // Also covers a first reply that finished in the background (#94).
373 _maybeAutoGenerateTitleAfterFirstReply(
374 conversationId: conversationId,
375 timeline: timeline,
376 );
377 }
378 }
379
380 void approveTool(bool approved) {
381 final pending = _pendingToolApproval;
382 if (pending != null && !pending.completer.isCompleted) {
383 pending.completer.complete(approved);
384 }
385 }
386
387 void _clearPendingApproval() {
388 final pending = _pendingToolApproval;
389 _pendingToolApproval = null;
390 if (pending != null) {
391 if (!pending.completer.isCompleted) {
392 pending.completer.complete(false);
393 }
394 if (ref.mounted) {
395 state = state.copyWith(clearPendingApproval: true);
396 }
397 }
398 }
399
400 @override
401 ChatState build() {
402 final db = ref.read(databaseProvider);
403 _saveService = MessageSaveService(db);
404 ref.onDispose(() {
405 for (final session in _sessions.values) {
406 session.cancelled = true;
407 session.cancelUiTimer();
408 session.subscription?.cancel();
409 session.subscription = null;
410 }
411 _sessions.clear();
412 _saveService?.dispose();
413 final pending = _pendingToolApproval;
414 _pendingToolApproval = null;
415 if (pending != null && !pending.completer.isCompleted) {
416 pending.completer.complete(false);
417 }
418 });
419 if (!_attemptedResume) {
420 _attemptedResume = true;
421 Future.microtask(_tryResumeLastChat);
422 }
423 return const ChatState();
424 }
425
426 Future<void> _tryResumeLastChat() async {
427 if (!ref.mounted) return;
428 final settings = ref.read(settingsProvider);
429 if (!settings.resumeLastChat) return;
430 if (state.messages.isNotEmpty || _currentConversationId != null) return;
431
432 final prefs = ref.read(sharedPreferencesProvider);
433 final lastId = prefs.getString(_lastActiveConversationKey);
434 if (lastId == null || lastId.isEmpty) return;
435
436 try {
437 final conversations = await ref.read(conv.conversationsProvider.future);
438 if (!ref.mounted) return;
439 Conversation? target;
440 for (final conversation in conversations) {
441 if (conversation.id == lastId && !conversation.isTemporary) {
442 target = conversation;
443 break;
444 }
445 }
446 if (target != null) {
447 await loadConversation(target);
448 }
449 } catch (e, st) {
450 Log.error('Failed to resume last chat: $e\n$st');
451 }
452 }
453
454 void _persistLastActiveConversation(String conversationId) {
455 if (!ref.mounted) return;
456 if (!ref.read(settingsProvider).resumeLastChat) return;
457 ref
458 .read(sharedPreferencesProvider)
459 .setString(_lastActiveConversationKey, conversationId);
460 }
461
462 Future<void> loadConversation(Conversation conversation) async {
463 if (!ref.mounted) return;
464 // Leave an eligible reply streaming in the background (#94). Only the
465 // open chat's own stream may be cancelled here; a reply already running
466 // in the background for another chat must survive further switches.
467 if (state.isStreaming && !_canContinueInBackground()) {
468 await cancelStream();
469 } else if (!state.isStreaming) {
470 _clearPendingApproval();
471 }
472 if (!ref.mounted) return;
473 _currentConversationId = conversation.id;
474 ref.read(smartReplyServiceProvider).reset();
475 state = state.copyWith(
476 isLoading: true,
477 errorMessage: null,
478 clearError: true,
479 );
480
481 try {
482 final messages = await loadConversationMessages(conversation.id);
483
484 if (!ref.mounted) return;
485
486 // Reattach to a reply that kept generating while this chat was closed.
487 final liveMessage = _sessions[conversation.id]?.latestMessage;
488 state = ChatState(
489 allMessages: messages,
490 messages: MessageVariants.resolveActiveTimeline(messages),
491 isLoading: false,
492 isTemporary: conversation.isTemporary,
493 isStreaming: liveMessage != null,
494 streamingMessage: liveMessage,
495 );
496 ref
497 .read(conv.activeConversationIdProvider.notifier)
498 .setActiveConversation(conversation);
499 _publishGenerations();
500
501 if (!conversation.isTemporary) {
502 _persistLastActiveConversation(conversation.id);
503 }
504
505 ref
506 .read(chatMcpConfigProvider.notifier)
507 .setEnabled(conversation.mcpEnabled ?? false);
508 } catch (e, stackTrace) {
509 Log.fatal(error: e, stackTrace: stackTrace);
510 if (ref.mounted) {
511 state = state.copyWith(
512 isLoading: false,
513 errorMessage: 'Failed to load conversation: $e',
514 );
515 }
516 }
517 }
518
519 static List<Message> _loadMessagesInBackground(
520 Store store,
521 String conversationId,
522 ) {
523 final convBox = store.box<ConversationEntity>();
524 final messageBox = store.box<MessageEntity>();
525
526 final convQuery = convBox
527 .query(ConversationEntity_.id.equals(conversationId))
528 .build();
529 final convEntity = convQuery.findFirst();
530 convQuery.close();
531
532 List<Message> messages = [];
533
534 if (convEntity != null) {
535 final relatedEntities = convEntity.messages;
536 if (relatedEntities.isNotEmpty) {
537 messages = relatedEntities.map((e) => e.toDomain()).toList();
538 } else {
539 final query = messageBox
540 .query(MessageEntity_.conversationUid.equals(conversationId))
541 .build();
542 final manualEntities = query.find();
543 query.close();
544
545 if (manualEntities.isNotEmpty) {
546 for (final e in manualEntities) {
547 e.conversation.target = convEntity;
548 messageBox.put(e);
549 }
550 messages = manualEntities.map((e) => e.toDomain()).toList();
551 }
552 }
553 } else {
554 final query = messageBox
555 .query(MessageEntity_.conversationUid.equals(conversationId))
556 .build();
557 final entities = query.find();
558 query.close();
559 messages = entities.map((e) => e.toDomain()).toList();
560 }
561
562 messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
563 final normalized = messages.asMap().entries.map((entry) {
564 final msg = entry.value;
565 if (msg.variantGroupId?.isNotEmpty == true) return msg;
566 return msg.copyWith(
567 variantGroupId: msg.id,
568 variantIndex: 0,
569 threadOrder: entry.key,
570 isActiveVariant: true,
571 );
572 }).toList();
573 return _assignLegacyParentsIfNeeded(normalized);
574 }
575
576 static List<Message> _assignLegacyParentsIfNeeded(List<Message> messages) {
577 if (messages.any((m) => m.parentMessageId?.isNotEmpty == true)) {
578 return messages;
579 }
580 final byThreadOrder = <int, List<Message>>{};
581 for (final message in messages) {
582 byThreadOrder.putIfAbsent(message.threadOrder, () => []).add(message);
583 }
584 final orders = byThreadOrder.keys.toList()..sort();
585 String? previousActiveId;
586 final updated = <Message>[];
587 for (final order in orders) {
588 final group = byThreadOrder[order]!;
589 for (final message in group) {
590 updated.add(message.copyWith(parentMessageId: previousActiveId));
591 }
592 final active = group.firstWhere(
593 (m) => m.isActiveVariant,
594 orElse: () => group.last,
595 );
596 previousActiveId = active.id;
597 }
598 return updated;
599 }
600
601 void _setAllMessages(List<Message> allMessages) {
602 state = state.copyWith(
603 allMessages: allMessages,
604 messages: MessageVariants.resolveActiveTimeline(allMessages),
605 );
606 }
607
608 Future<void> startNewConversation() async {
609 if (!ref.mounted) return;
610 if (state.isStreaming && !_canContinueInBackground()) {
611 await _abortStreamImmediately();
612 } else {
613 _clearPendingApproval();
614 }
615 if (!ref.mounted) return;
616
617 _currentConversationId = null;
618 _ephemeralConversationId = null;
619 _pendingTemporaryChat = false;
620 ref.read(smartReplyServiceProvider).reset();
621 state = const ChatState();
622 ref
623 .read(conv.activeConversationIdProvider.notifier)
624 .setActiveConversation(null);
625 _publishGenerations();
626 ref.read(chatOriginProvider.notifier).clear();
627 ref.read(messageSelectionModeProvider.notifier).disable();
628
629 final settings = ref.read(settingsProvider);
630 ref
631 .read(chatMcpConfigProvider.notifier)
632 .setEnabled(settings.mcpEnabled && settings.newChatMcpEnabled);
633 }
634
635 /// Cancels the open chat's reply right away (before another one starts or
636 /// the chat is left) and marks its in-memory message cancelled. Replies
637 /// running for other chats are never touched.
638 Future<void> _abortStreamImmediately() async {
639 final session = _activeSession;
640 _clearPendingApproval();
641 if (session == null && state.streamingMessage == null) return;
642 await session?.detach();
643 if (!ref.mounted) return;
644
645 session?.chatService.cancelStream();
646 if (session != null) _endSession(session);
647
648 final convId = _currentConversationId;
649 final streamingMessage = session?.latestMessage ?? state.streamingMessage;
650 session?.latestMessage = null;
651
652 if (streamingMessage != null && convId != null) {
653 // Mark the in-memory copy as cancelled and inactive so it does not stay
654 // visible in the active timeline while the next stream starts.
655 final cancelled = streamingMessage.copyWith(
656 conversationId: convId,
657 status: MessageStatus.cancelled,
658 isProcessing: false,
659 isActiveVariant: false,
660 );
661 final updatedAll = state.allMessages.map((m) {
662 return m.id == cancelled.id ? cancelled : m;
663 }).toList();
664 state = state.copyWith(
665 allMessages: updatedAll,
666 messages: MessageVariants.resolveActiveTimeline(updatedAll),
667 );
668
669 unawaited(
670 _persistCancelledMessageInBackground(
671 streamingMessage,
672 convId,
673 session: session,
674 ),
675 );
676 }
677 }
678
679 Future<void> _persistCancelledMessageInBackground(
680 Message streamingMessage,
681 String conversationId, {
682 GenerationSession? session,
683 }) async {
684 try {
685 final finalMessage = _finalizeStreamMessage(
686 streamingMessage.copyWith(
687 conversationId: conversationId,
688 status: MessageStatus.cancelled,
689 isProcessing: false,
690 ),
691 stopReason: 'cancelled',
692 session: session,
693 );
694 await _saveService?.flush();
695 await _saveMessage(finalMessage, persist: session?.persisted);
696 } catch (e) {
697 Log.error('Failed to persist cancelled stream message: $e');
698 }
699 }
700
701 void setTemporaryMode(bool enabled) {
702 if (state.messages.isNotEmpty && enabled != state.isTemporary) return;
703 _pendingTemporaryChat = enabled;
704 state = state.copyWith(isTemporary: enabled);
705 }
706
707 String generateUuid() {
708 final random = Random.secure();
709 final bytes = List<int>.generate(16, (_) => random.nextInt(256));
710 bytes[6] = (bytes[6] & 0x0f) | 0x40;
711 bytes[8] = (bytes[8] & 0x3f) | 0x80;
712 return [
713 bytes.sublist(0, 4),
714 bytes.sublist(4, 6),
715 bytes.sublist(6, 8),
716 bytes.sublist(8, 10),
717 bytes.sublist(10, 16),
718 ]
719 .map((b) => b.map((e) => e.toRadixString(16).padLeft(2, '0')).join())
720 .join('-');
721 }
722
723 /// Writes the latest snapshot of every persisted running reply to disk, so
724 /// nothing is lost if the app is killed while it is in the background.
725 Future<void> checkpointStreamingMessage({bool flush = false}) async {
726 for (final session in _sessions.values) {
727 final latest = session.latestMessage;
728 if (!session.persisted || latest == null) continue;
729 _saveService?.enqueue(latest);
730 session.updateSavedMetrics(latest);
731 session.resetCheckpointMetrics();
732 }
733
734 if (flush) {
735 await _saveService?.flush();
736 }
737 }
738
739 void _replaceMessageInState(Message message, {bool clearStreaming = false}) {
740 _replaceMessageInAll(message, clearStreaming: clearStreaming);
741 }
742
743 /// Creates a new conversation (or ephemeral in-memory chat) if one isn't
744 /// already active, mirroring the same persona/title/MCP setup [sendMessage]
745 /// has always done on the first message of a chat. Shared with
746 /// [insertMessageWithoutGenerating] so manually-inserted messages can also
747 /// start a brand new conversation.
748 Future<void> _ensureConversationExists(String titleSource) async {
749 if (_currentConversationId != null || _ephemeralConversationId != null) {
750 return;
751 }
752
753 final target = ref.read(activeChatTargetProvider);
754 final server = target.server;
755 final settings = ref.read(settingsProvider);
756 if (server == null) return;
757
758 final isTemp = state.isTemporary || _pendingTemporaryChat;
759 if (isTemp) {
760 _ephemeralConversationId = generateUuid();
761 _pendingTemporaryChat = false;
762 state = state.copyWith(isTemporary: true);
763 return;
764 }
765
766 final titleService = ref.read(titleGenerationServiceProvider);
767 final initialTitle = settings.autoGenerateTitle
768 ? 'New Chat'
769 : titleService.truncateFirstMessageTitle(titleSource);
770 final preselected = ref.read(selectedPersonasProvider);
771 final newConversationSystemPrompt = preselected.isEmpty
772 ? null
773 : PersonaPromptUtils.combineSystemPrompts(preselected);
774 final conversation = await ref
775 .read(conv.conversationsProvider.notifier)
776 .createConversation(
777 title: initialTitle,
778 serverId: server.id,
779 modelId: target.effectiveModelId,
780 personaId: preselected.isEmpty
781 ? null
782 : PersonaPromptUtils.joinPersonaIds(
783 preselected.map((p) => p.id).toList(),
784 ),
785 systemPrompt: newConversationSystemPrompt,
786 mcpEnabled: settings.mcpEnabled && settings.newChatMcpEnabled,
787 isTemporary: false,
788 folderId: ref.read(pendingNewChatFolderIdProvider.notifier).consume(),
789 );
790 if (!ref.mounted) return;
791 _currentConversationId = conversation.id;
792 ref
793 .read(conv.activeConversationIdProvider.notifier)
794 .setActiveConversation(conversation);
795 _persistLastActiveConversation(conversation.id);
796 // A conversationsProvider refresh triggered later in this same send
797 // (e.g. syncConversationStats) rebuilds activeConversationProvider
798 // from the reloaded list; use this captured value for this send so
799 // the persona system prompt is never missed on the very first
800 // message of a brand new conversation.
801 _useFreshConversationSystemPrompt = true;
802 _freshConversationSystemPrompt = newConversationSystemPrompt;
803
804 ref
805 .read(chatMcpConfigProvider.notifier)
806 .setEnabled(settings.mcpEnabled && settings.newChatMcpEnabled);
807
808 if (!settings.keepPersonaOnNewChat) {
809 ref.read(selectedPersonasProvider.notifier).clear();
810 }
811 }
812
813 Future<void> sendMessage(String content, {List<File>? attachments}) async {
814 if (_blockIfOnDeviceBusy()) return;
815 final target = ref.read(activeChatTargetProvider);
816 final server = target.server;
817 final selectedModel = target.selectedModel;
818 final effectiveModelId = target.effectiveModelId;
819
820 if (server == null) {
821 state = state.copyWith(errorMessage: 'No server connected');
822 return;
823 }
824
825 if (effectiveModelId == null) {
826 state = state.copyWith(errorMessage: modelSelectionRequiredMessage);
827 return;
828 }
829
830 if (content.trim().isEmpty &&
831 (attachments == null || attachments.isEmpty)) {
832 return;
833 }
834
835 final chatService = ref.read(chatServiceFactoryProvider)(server);
836 if (chatService == null) {
837 state = state.copyWith(errorMessage: 'No chat service available');
838 return;
839 }
840
841 await _abortStreamImmediately();
842 if (!ref.mounted) return;
843
844 // Stop the STT listener so the mic doesn't stay red while the model
845 // thinks. The chat input bar's `ref.listen` will re-start it once TTS
846 // (if any) finishes.
847 try {
848 ref.read(stt.sttProvider.notifier).stopListening();
849 } catch (_) {
850 // STT may not be initialised — non-fatal.
851 }
852
853 final trimmedContent = content.trim();
854
855 final titleSource = trimmedContent.isNotEmpty
856 ? trimmedContent
857 : (attachments?.isNotEmpty == true
858 ? attachments!.first.path.split(Platform.pathSeparator).last
859 : 'New Chat');
860 await _ensureConversationExists(titleSource);
861
862 // Read after the conversation (and its persona system prompt) is
863 // created above — chatParamsProvider derives systemPrompt from
864 // activeConversationProvider, which doesn't have it yet if read at the
865 // top of this function, causing the very first message's request to
866 // go out with no persona system prompt.
867 final chatParams = ref.read(chatParamsProvider);
868
869 final convId = _activeConversationId;
870 if (convId == null) return;
871
872 final List<String> savedPaths = [];
873 if (attachments != null && attachments.isNotEmpty) {
874 final appDir = await ref.read(storageDirectoryProvider.future);
875 final attachmentsDir = Directory('${appDir.path}/attachments');
876 if (!await attachmentsDir.exists()) {
877 await attachmentsDir.create(recursive: true);
878 }
879
880 for (final file in attachments) {
881 final savedPath = await AttachmentHelpers.saveAttachment(
882 file,
883 attachmentsDir,
884 );
885 if (savedPath != null) savedPaths.add(savedPath);
886 }
887 }
888
889 final userThreadOrder = MessageVariants.nextThreadOrder(state.messages);
890 final userGroupId = generateUuid();
891 final assistantThreadOrder = userThreadOrder + 1;
892 final assistantGroupId = generateUuid();
893 final lastInTimeline = state.messages.isNotEmpty
894 ? state.messages.last
895 : null;
896
897 final userMessage = Message(
898 id: generateUuid(),
899 conversationId: convId,
900 role: MessageRole.user,
901 content: trimmedContent,
902 createdAt: DateTime.now(),
903 status: MessageStatus.complete,
904 attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
905 variantGroupId: userGroupId,
906 variantIndex: 0,
907 threadOrder: userThreadOrder,
908 isActiveVariant: true,
909 parentMessageId: lastInTimeline?.id,
910 );
911
912 final assistantMessageId = generateUuid();
913 var assistantMessage = Message(
914 id: assistantMessageId,
915 conversationId: convId,
916 role: MessageRole.assistant,
917 content: '',
918 createdAt: DateTime.now(),
919 status: MessageStatus.streaming,
920 modelId: effectiveModelId,
921 variantGroupId: assistantGroupId,
922 variantIndex: 0,
923 threadOrder: assistantThreadOrder,
924 isActiveVariant: true,
925 parentMessageId: userMessage.id,
926 );
927
928 final updatedAll = [...state.allMessages, userMessage, assistantMessage];
929 state = state.copyWith(
930 allMessages: updatedAll,
931 messages: MessageVariants.resolveActiveTimeline(updatedAll),
932 isStreaming: true,
933 streamingMessage: assistantMessage,
934 clearError: true,
935 );
936 final session = _beginSession(convId, server, chatService);
937
938 await _saveMessage(userMessage, persist: session.persisted);
939 await _saveMessage(assistantMessage, persist: session.persisted);
940
941 if (!ref.mounted) return;
942
943 if (!_isInMemoryChat && _currentConversationId != null) {
944 final preview = trimmedContent.isNotEmpty
945 ? (trimmedContent.length > 100
946 ? '${trimmedContent.substring(0, 100)}...'
947 : trimmedContent)
948 : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
949 final timeline = [...state.messages, userMessage];
950 await ref
951 .read(conv.conversationsProvider.notifier)
952 .syncConversationStats(
953 _currentConversationId!,
954 messageCount: timeline.length,
955 characterCount: timeline.fold<int>(
956 0,
957 (sum, message) => sum + message.content.length,
958 ),
959 preview: preview,
960 );
961 }
962
963 session.resetStreamMetrics();
964 session.resetCheckpointMetrics();
965 session.updateSavedMetrics(assistantMessage);
966
967 final messagesForApi = _buildMessagesForApi(selectedModel);
968
969 try {
970 String reasoningContent = '';
971 var streamingAssistantMessage = assistantMessage;
972 session.latestMessage = streamingAssistantMessage;
973
974 if (!session.cancelled) {
975 final mcpConfig = ref.read(chatMcpConfigProvider);
976 final activeIntegrations = mcpConfig.integrations
977 .where((i) => i.enabled)
978 .toList();
979 final integrations = mcpConfig.enabled && activeIntegrations.isNotEmpty
980 ? activeIntegrations
981 : null;
982
983 final registry = ref.read(toolRegistryProvider);
984 final tools = mcpConfig.enabled
985 ? await registry.listTools()
986 : const <ToolDefinition>[];
987
988 final collectedToolCalls = <ToolCallData>[];
989
990 bool stateNeedsUpdate = false;
991 bool streamHadError = false;
992
993 void updateUiState() {
994 if (!stateNeedsUpdate || !ref.mounted) return;
995 stateNeedsUpdate = false;
996
997 final streamConvId = assistantMessage.conversationId;
998 final isCurrentContext = _currentConversationId == streamConvId;
999
1000 if (isCurrentContext) {
1001 state = state.copyWith(streamingMessage: streamingAssistantMessage);
1002 }
1003 }
1004
1005 session.uiUpdateTimer = Timer.periodic(
1006 const Duration(milliseconds: 80),
1007 (_) {
1008 updateUiState();
1009 },
1010 );
1011
1012 session.subscription = chatService
1013 .sendMessage(
1014 server: server,
1015 modelId: effectiveModelId,
1016 messages: messagesForApi,
1017 params: chatParams,
1018 integrations: integrations,
1019 tools: tools,
1020 )
1021 .listen(
1022 (response) async {
1023 if (session.cancelled || !ref.mounted) {
1024 return;
1025 }
1026 final streamConvId = assistantMessage.conversationId;
1027 final isCurrentContext = _currentConversationId == streamConvId;
1028
1029 switch (response.type) {
1030 case ChatResponseType.message:
1031 if (response.content?.isNotEmpty ?? false) {
1032 session.noteFirstToken();
1033 }
1034 streamingAssistantMessage = streamingAssistantMessage
1035 .copyWith(
1036 content:
1037 streamingAssistantMessage.content +
1038 (response.content ?? ''),
1039 isProcessing: false,
1040 );
1041 session.latestMessage = streamingAssistantMessage;
1042
1043 session.chunkCount++;
1044
1045 if (session.persisted &&
1046 session.shouldCheckpointSave(
1047 streamingAssistantMessage,
1048 )) {
1049 _saveService?.enqueue(streamingAssistantMessage);
1050 session.updateSavedMetrics(streamingAssistantMessage);
1051 session.resetCheckpointMetrics();
1052 }
1053
1054 stateNeedsUpdate = true;
1055 break;
1056 case ChatResponseType.reasoning:
1057 if (response.reasoningContent?.isNotEmpty ?? false) {
1058 session.noteFirstToken();
1059 }
1060 reasoningContent += response.reasoningContent ?? '';
1061 streamingAssistantMessage = streamingAssistantMessage
1062 .copyWith(
1063 reasoningContent: reasoningContent,
1064 isProcessing: false,
1065 );
1066 session.latestMessage = streamingAssistantMessage;
1067
1068 session.chunkCount++;
1069
1070 if (session.persisted &&
1071 session.shouldCheckpointSave(
1072 streamingAssistantMessage,
1073 )) {
1074 _saveService?.enqueue(streamingAssistantMessage);
1075 session.updateSavedMetrics(streamingAssistantMessage);
1076 session.resetCheckpointMetrics();
1077 }
1078
1079 stateNeedsUpdate = true;
1080 break;
1081 case ChatResponseType.processing:
1082 final wasProcessing =
1083 streamingAssistantMessage.isProcessing;
1084 streamingAssistantMessage = streamingAssistantMessage
1085 .copyWith(isProcessing: true);
1086 session.latestMessage = streamingAssistantMessage;
1087 stateNeedsUpdate = true;
1088 if (!wasProcessing) {
1089 ref.read(appHapticsProvider).light();
1090 }
1091 break;
1092 case ChatResponseType.timeoutError:
1093 case ChatResponseType.error:
1094 streamHadError = true;
1095 session.cancelUiTimer();
1096 streamingAssistantMessage = streamingAssistantMessage.copyWith(
1097 status: MessageStatus.error,
1098 errorMessage:
1099 response.content ??
1100 (response.type == ChatResponseType.timeoutError
1101 ? 'Model is taking too long to respond. This may happen with free tier models.'
1102 : 'An unknown error occurred.'),
1103 isProcessing: false,
1104 );
1105 session.latestMessage = streamingAssistantMessage;
1106
1107 if (isCurrentContext) {
1108 _replaceMessageInState(
1109 streamingAssistantMessage,
1110 clearStreaming: true,
1111 );
1112 state = state.copyWith(
1113 isStreaming: false,
1114 errorMessage: streamingAssistantMessage.errorMessage,
1115 );
1116 }
1117 _endSession(session);
1118 session.latestMessage = null;
1119 await _saveService?.flush();
1120 await _saveMessage(
1121 streamingAssistantMessage,
1122 persist: session.persisted,
1123 );
1124 break;
1125 case ChatResponseType.toolCall:
1126 if (response.toolCall != null) {
1127 collectedToolCalls.add(response.toolCall!);
1128 }
1129 break;
1130 case ChatResponseType.invalidToolCall:
1131 case ChatResponseType.done:
1132 if (response.stats != null) {
1133 session.stats = response.stats;
1134 }
1135 break;
1136 }
1137 },
1138 onDone: () async {
1139 session.cancelUiTimer();
1140 updateUiState();
1141
1142 if (streamHadError) {
1143 // The error branch already finalised the message and
1144 // stopped streaming — do not overwrite the error state
1145 // with success/default text.
1146 if (session.persisted) {
1147 await _saveService?.flush();
1148 }
1149 return;
1150 }
1151
1152 if (session.persisted) {
1153 await _saveService?.flush();
1154 }
1155 final streamConvId = assistantMessage.conversationId;
1156 final isCurrentContext = _activeConversationId == streamConvId;
1157 final streamingMessage = streamingAssistantMessage;
1158 final hasContent =
1159 streamingMessage.content.isNotEmpty ||
1160 (streamingMessage.reasoningContent?.isNotEmpty ?? false) ||
1161 collectedToolCalls.isNotEmpty;
1162
1163 if (!hasContent) {
1164 final errorMessage = streamingMessage.copyWith(
1165 status: MessageStatus.error,
1166 errorMessage:
1167 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
1168 isProcessing: false,
1169 );
1170 await _saveMessage(errorMessage, persist: session.persisted);
1171 if (ref.mounted && isCurrentContext) {
1172 _replaceMessageInState(errorMessage, clearStreaming: true);
1173 state = state.copyWith(
1174 isStreaming: false,
1175 errorMessage: errorMessage.errorMessage,
1176 );
1177 }
1178 if (ref.mounted) {
1179 _endSession(session);
1180 }
1181 session.latestMessage = null;
1182 } else {
1183 var finalMessage = _finalizeStreamMessage(
1184 streamingMessage.copyWith(
1185 status: MessageStatus.complete,
1186 isProcessing: false,
1187 ),
1188 stopReason: 'complete',
1189 session: session,
1190 );
1191
1192 if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
1193 try {
1194 // Split collected tool calls into:
1195 // - `serverExecuted`: calls whose output the server
1196 // already populated (e.g. LM Studio's native
1197 // /api/v1/chat runs MCP tools server-side). These
1198 // must NOT be re-executed by the client.
1199 // - `clientExecuted`: calls that arrived without
1200 // output (OpenAI-compatible streaming tool calls).
1201 // These are run by ToolExecutionLoop as before.
1202 final dedupedCalls = <String, ToolCallData>{};
1203 for (final tc in collectedToolCalls) {
1204 dedupedCalls[tc.tool] = tc;
1205 }
1206 final allCalls = dedupedCalls.values.toList();
1207 final serverExecuted = allCalls
1208 .where((tc) => tc.output != null)
1209 .toList();
1210 final clientExecuted = allCalls
1211 .where((tc) => tc.output == null)
1212 .toList();
1213
1214 final toolEvents = <ToolEvent>[];
1215
1216 if (serverExecuted.isNotEmpty) {
1217 final sessionId = DateTime.now().millisecondsSinceEpoch
1218 .toString();
1219 for (final tc in serverExecuted) {
1220 final eventId =
1221 '${sessionId}_1_${tc.tool}.server_executed';
1222 toolEvents.add(
1223 ToolEvent(
1224 eventId: eventId,
1225 timestamp: DateTime.now(),
1226 status: ToolEventStatus.completed,
1227 toolName: tc.tool,
1228 providerType: ToolProviderType.lmStudioServer,
1229 arguments: tc.arguments,
1230 result: tc.output,
1231 ),
1232 );
1233 }
1234 }
1235
1236 if (clientExecuted.isNotEmpty && ref.mounted) {
1237 final registry = ref.read(toolRegistryProvider);
1238 final adapter = createAdapterForServerType(server.type);
1239
1240 final preParsedCalls = clientExecuted
1241 .map(
1242 (tc) => ParsedToolCall(
1243 id: tc.tool,
1244 name: tc.tool,
1245 arguments: tc.arguments,
1246 ),
1247 )
1248 .toList();
1249
1250 final loop = ToolExecutionLoop(
1251 adapter: adapter,
1252 registry: registry,
1253 onRequestApproval: (call) async {
1254 final completer = Completer<bool>();
1255 final approval = PendingToolApproval(
1256 toolCall: call,
1257 completer: completer,
1258 );
1259 _pendingToolApproval = approval;
1260 if (ref.mounted) {
1261 state = state.copyWith(
1262 pendingToolApproval: approval,
1263 );
1264 }
1265
1266 final result = await completer.future;
1267
1268 _pendingToolApproval = null;
1269 if (ref.mounted) {
1270 state = state.copyWith(
1271 clearPendingApproval: true,
1272 );
1273 }
1274
1275 return result;
1276 },
1277 );
1278
1279 final loopResult = await loop.run(
1280 initialUserMessage: content,
1281 assistantContent: streamingAssistantMessage.content,
1282 preParsedCalls: preParsedCalls,
1283 );
1284
1285 if (loopResult.events.isNotEmpty) {
1286 toolEvents.addAll(loopResult.events);
1287 }
1288 }
1289
1290 if (toolEvents.isNotEmpty) {
1291 finalMessage = finalMessage.copyWith(
1292 toolEvents: toolEvents,
1293 );
1294 }
1295
1296 // After successful tool execution the chat must
1297 // continue: the model has emitted a tool call, the
1298 // client (or server) executed it, and now we need to
1299 // send a follow-up chat completion containing the
1300 // tool-role result(s) so the model can produce the
1301 // final answer. Without this, the conversation just
1302 // ends as soon as the tool returns — see issue #77.
1303 final completedResults = toolEvents
1304 .where((e) => e.status == ToolEventStatus.completed)
1305 .toList();
1306 if (ref.mounted && completedResults.isNotEmpty) {
1307 await _sendFollowupWithToolResults(
1308 previousAssistant: finalMessage,
1309 toolEvents: toolEvents,
1310 server: server,
1311 selectedModel: selectedModel,
1312 effectiveModelId: effectiveModelId,
1313 chatService: chatService,
1314 chatParams: chatParams,
1315 tools: tools,
1316 integrations: integrations,
1317 streamConvId: streamConvId,
1318 isCurrentContext: isCurrentContext,
1319 );
1320 // After the follow-up the original `finalMessage`
1321 // already contains its `toolEvents` and is on disk;
1322 // mark it as final and bail out of the rest of the
1323 // post-stream bookkeeping that was meant for the
1324 // first turn only.
1325 if (ref.mounted) {
1326 await _saveMessage(
1327 finalMessage,
1328 persist: session.persisted,
1329 );
1330 if (isCurrentContext) {
1331 _replaceMessageInState(
1332 finalMessage,
1333 clearStreaming: true,
1334 );
1335 }
1336 }
1337 return;
1338 }
1339 } catch (e) {
1340 Log.error('Tool execution loop failed: $e');
1341 // A follow-up that failed after registering its own
1342 // session must not leave it running forever.
1343 final followUp = _sessions[session.conversationId];
1344 if (followUp != null && !identical(followUp, session)) {
1345 await followUp.detach();
1346 _endSession(followUp);
1347 }
1348 }
1349 }
1350
1351 await _saveMessage(finalMessage, persist: session.persisted);
1352 if (ref.mounted && isCurrentContext) {
1353 _replaceMessageInState(finalMessage, clearStreaming: true);
1354 state = state.copyWith(isStreaming: false);
1355 _maybeAutoGenerateTitleAfterFirstReply();
1356 }
1357 if (ref.mounted) {
1358 _endSession(session);
1359 }
1360 session.latestMessage = null;
1361
1362 await _syncConversationStatsAfterGeneration(
1363 streamConvId,
1364 finalMessage,
1365 isCurrentContext: isCurrentContext,
1366 );
1367
1368 if (ref.mounted) {
1369 _maybeRequestReviewAfterSuccessfulCompletion(
1370 finalMessage: finalMessage,
1371 server: server,
1372 selectedModel: selectedModel,
1373 );
1374 }
1375
1376 // Auto-speak: read the response aloud if the setting
1377 // is enabled and voice mode is not already handling TTS.
1378 if (ref.mounted) {
1379 final autoSpeak = ref
1380 .read(settingsProvider)
1381 .autoSpeakEnabled;
1382 final voiceActive =
1383 ref.read(voiceModeProvider).phase !=
1384 VoiceModePhase.idle;
1385 // Don't read a background reply aloud over another
1386 // chat (#94).
1387 if (autoSpeak &&
1388 !voiceActive &&
1389 _activeConversationId == finalMessage.conversationId &&
1390 finalMessage.content.trim().isNotEmpty) {
1391 ref
1392 .read(ttsProvider.notifier)
1393 .speak(
1394 finalMessage.content,
1395 messageId: finalMessage.id,
1396 conversationId: finalMessage.conversationId,
1397 );
1398 }
1399 }
1400 }
1401 },
1402 onError: (error) async {
1403 session.cancelUiTimer();
1404 await _saveService?.flush();
1405 final errorMessage = streamingAssistantMessage.copyWith(
1406 status: MessageStatus.error,
1407 errorMessage: error.toString(),
1408 );
1409 await _saveMessage(errorMessage, persist: session.persisted);
1410
1411 final streamConvId = assistantMessage.conversationId;
1412 if (ref.mounted && _currentConversationId == streamConvId) {
1413 _replaceMessageInState(errorMessage, clearStreaming: true);
1414 state = state.copyWith(
1415 isStreaming: false,
1416 errorMessage: error.toString(),
1417 );
1418 }
1419 if (ref.mounted) {
1420 _endSession(session);
1421 }
1422 session.latestMessage = null;
1423 },
1424 );
1425 }
1426 } catch (e) {
1427 session.cancelUiTimer();
1428 final errorMsg = assistantMessage.copyWith(
1429 status: MessageStatus.error,
1430 errorMessage: e.toString(),
1431 isProcessing: false,
1432 );
1433 await _saveMessage(errorMsg, persist: session.persisted);
1434
1435 if (!ref.mounted) return;
1436
1437 state = state.copyWith(
1438 isStreaming: false,
1439 errorMessage: e.toString(),
1440 clearStreaming: true,
1441 );
1442 _endSession(session);
1443 session.latestMessage = null;
1444 }
1445 }
1446
1447 /// Inserts [content] as a message with the given [role] without calling
1448 /// the chat service or starting generation. Used by the send button's
1449 /// long-press ("insert without generating") and the role-swap button
1450 /// (which lets the user manually author an assistant-role turn).
1451 Future<void> insertMessageWithoutGenerating(
1452 String content, {
1453 required MessageRole role,
1454 List<File>? attachments,
1455 }) async {
1456 final server = ref.read(activeServerProvider);
1457 if (server == null) {
1458 state = state.copyWith(errorMessage: 'No server connected');
1459 return;
1460 }
1461
1462 final trimmedContent = content.trim();
1463 if (trimmedContent.isEmpty &&
1464 (attachments == null || attachments.isEmpty)) {
1465 return;
1466 }
1467
1468 final titleSource = trimmedContent.isNotEmpty
1469 ? trimmedContent
1470 : (attachments?.isNotEmpty == true
1471 ? attachments!.first.path.split(Platform.pathSeparator).last
1472 : 'New Chat');
1473 await _ensureConversationExists(titleSource);
1474
1475 final convId = _activeConversationId;
1476 if (convId == null) return;
1477
1478 final List<String> savedPaths = [];
1479 if (attachments != null && attachments.isNotEmpty) {
1480 final appDir = await ref.read(storageDirectoryProvider.future);
1481 final attachmentsDir = Directory('${appDir.path}/attachments');
1482 if (!await attachmentsDir.exists()) {
1483 await attachmentsDir.create(recursive: true);
1484 }
1485
1486 for (final file in attachments) {
1487 final savedPath = await AttachmentHelpers.saveAttachment(
1488 file,
1489 attachmentsDir,
1490 );
1491 if (savedPath != null) savedPaths.add(savedPath);
1492 }
1493 }
1494
1495 final threadOrder = MessageVariants.nextThreadOrder(state.messages);
1496 final groupId = generateUuid();
1497 final lastInTimeline = state.messages.isNotEmpty
1498 ? state.messages.last
1499 : null;
1500
1501 final message = Message(
1502 id: generateUuid(),
1503 conversationId: convId,
1504 role: role,
1505 content: trimmedContent,
1506 createdAt: DateTime.now(),
1507 status: MessageStatus.complete,
1508 attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
1509 variantGroupId: groupId,
1510 variantIndex: 0,
1511 threadOrder: threadOrder,
1512 isActiveVariant: true,
1513 parentMessageId: lastInTimeline?.id,
1514 );
1515
1516 final updatedAll = [...state.allMessages, message];
1517 state = state.copyWith(
1518 allMessages: updatedAll,
1519 messages: MessageVariants.resolveActiveTimeline(updatedAll),
1520 clearError: true,
1521 );
1522
1523 await _saveMessage(message);
1524
1525 if (!ref.mounted) return;
1526
1527 if (!_isInMemoryChat && _currentConversationId != null) {
1528 final preview = trimmedContent.isNotEmpty
1529 ? (trimmedContent.length > 100
1530 ? '${trimmedContent.substring(0, 100)}...'
1531 : trimmedContent)
1532 : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
1533 final timeline = state.messages;
1534 await ref
1535 .read(conv.conversationsProvider.notifier)
1536 .syncConversationStats(
1537 _currentConversationId!,
1538 messageCount: timeline.length,
1539 characterCount: timeline.fold<int>(
1540 0,
1541 (sum, message) => sum + message.content.length,
1542 ),
1543 preview: preview,
1544 );
1545 }
1546 }
1547
1548 List<Message> _buildMessagesForApi(ModelInfo? selectedModel) {
1549 final settings = ref.read(settingsProvider);
1550 final messages = <Message>[];
1551
1552 final reasoningConfig = ref.read(chatReasoningConfigProvider);
1553 final reasoningMandatory = selectedModel?.reasoningMandatory ?? false;
1554 final shouldDisableThinking =
1555 (selectedModel?.supportsReasoning ?? false) &&
1556 !reasoningMandatory &&
1557 !reasoningConfig.enabled;
1558
1559 final personaPrompt = _getPersonaSystemPrompt();
1560 var systemContent =
1561 personaPrompt ??
1562 (settings.showSystemMessages
1563 ? 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.'
1564 : null);
1565
1566 final isVoiceActive = ref.read(voiceModeProvider).isActive;
1567 if (isVoiceActive && settings.conciseVoiceResponsesEnabled) {
1568 const conciseInstruction =
1569 'Respond concisely in a single short paragraph and end with a relevant follow-up question to keep the spoken conversation engaging.';
1570 systemContent = (systemContent == null || systemContent.trim().isEmpty)
1571 ? conciseInstruction
1572 : '$systemContent\n$conciseInstruction';
1573 }
1574
1575 if (shouldDisableThinking) {
1576 // Hybrid reasoning models (Qwen3 and similar) key off this literal
1577 // token in the prompt to skip their <think> block — send it whenever
1578 // the model supports reasoning and the user has switched Think off,
1579 // alongside the request-level reasoning-disable fields.
1580 systemContent = (systemContent == null || systemContent.trim().isEmpty)
1581 ? '/no_think'
1582 : '$systemContent\n/no_think';
1583 }
1584
1585 if (systemContent != null) {
1586 messages.add(
1587 Message(
1588 id: 'system-$_currentConversationId',
1589 conversationId: _currentConversationId ?? '',
1590 role: MessageRole.system,
1591 content: systemContent,
1592 createdAt: DateTime.now(),
1593 status: MessageStatus.complete,
1594 ),
1595 );
1596 }
1597
1598 for (final message in state.messages) {
1599 if (!shouldIncludeMessageInChatContext(message)) {
1600 continue;
1601 }
1602 if (message.role != MessageRole.system || settings.showSystemMessages) {
1603 messages.add(message);
1604 }
1605 }
1606
1607 final contextLength = ref.read(chatParamsProvider).contextLength;
1608 return _truncateToContextWindow(messages, contextLength);
1609 }
1610
1611 List<Message> _buildMessagesForContinue(
1612 ModelInfo? selectedModel,
1613 Message assistantMessage,
1614 ) {
1615 final messages = _buildMessagesForApi(selectedModel);
1616 if (messages.isEmpty) return messages;
1617 if (messages.last.role != MessageRole.assistant) return messages;
1618
1619 messages[messages.length - 1] = assistantMessage.copyWith(
1620 status: MessageStatus.complete,
1621 isProcessing: false,
1622 );
1623 return messages;
1624 }
1625
1626 String? _getPersonaSystemPrompt() {
1627 if (_useFreshConversationSystemPrompt) {
1628 _useFreshConversationSystemPrompt = false;
1629 final override = _freshConversationSystemPrompt;
1630 _freshConversationSystemPrompt = null;
1631 if (override != null && override.trim().isNotEmpty) return override;
1632 }
1633
1634 final conversation = ref.read(conv.activeConversationProvider);
1635 if (conversation?.systemPrompt != null &&
1636 conversation!.systemPrompt!.trim().isNotEmpty) {
1637 return conversation.systemPrompt;
1638 }
1639
1640 final personaIds = conversation?.personaId;
1641 if (personaIds == null || personaIds.isEmpty) {
1642 return _defaultSystemPrompt();
1643 }
1644
1645 try {
1646 final personas = ref.read(personasNotifierProvider).value ?? [];
1647 final selected = PersonaPromptUtils.resolvePersonas(personaIds, personas);
1648 if (selected.isEmpty) return _defaultSystemPrompt();
1649 return PersonaPromptUtils.combineSystemPrompts(selected);
1650 } catch (_) {}
1651
1652 return _defaultSystemPrompt();
1653 }
1654
1655 /// Settings fallback, used only when the chat has no prompt of its own and
1656 /// no persona (#81).
1657 String? _defaultSystemPrompt() {
1658 final prompt = ref.read(settingsProvider).defaultSystemPrompt.trim();
1659 return prompt.isEmpty ? null : prompt;
1660 }
1661
1662 List<Message> _truncateToContextWindow(
1663 List<Message> messages,
1664 int contextLength,
1665 ) {
1666 if (messages.isEmpty) return messages;
1667
1668 int estimatedTokens = 0;
1669 final result = <Message>[];
1670 const tokensPerChar = 4;
1671
1672 for (int i = messages.length - 1; i >= 0; i--) {
1673 final message = messages[i];
1674 estimatedTokens += (message.content.length / tokensPerChar).ceil();
1675
1676 if (estimatedTokens > contextLength) {
1677 break;
1678 }
1679 result.insert(0, message);
1680 }
1681
1682 return result;
1683 }
1684
1685 /// Waits for any in-flight AI title generation on [conversationId] to complete.
1686 /// Used by downstream post-generation tasks (like smart reply generation)
1687 /// so LLM inference calls execute sequentially without colliding on local models.
1688 Future<void> waitForTitleGeneration(String conversationId) async {
1689 final future = _titleGenerationFutures[conversationId];
1690 if (future != null) {
1691 try {
1692 await future;
1693 } catch (_) {
1694 // Title generation errors should not fail downstream consumers.
1695 }
1696 }
1697 }
1698
1699 /// Returns true if AI title generation is currently in flight for [conversationId].
1700 bool isTitleGenerationInFlight(String conversationId) {
1701 return _titleGenerationsInFlight.contains(conversationId) ||
1702 _titleGenerationFutures.containsKey(conversationId);
1703 }
1704
1705 /// Titles a chat after its first reply. Defaults to the open chat; pass
1706 /// [conversationId] and its [timeline] for a reply that finished in the
1707 /// background.
1708 Future<void>? _maybeAutoGenerateTitleAfterFirstReply({
1709 String? conversationId,
1710 List<Message>? timeline,
1711 }) {
1712 final convId = conversationId ?? _currentConversationId;
1713 if (convId == null) return null;
1714 final isOpenChat = convId == _currentConversationId;
1715 if (isOpenChat && _isInMemoryChat) return null;
1716
1717 final settings = ref.read(settingsProvider);
1718 if (!settings.autoGenerateTitle) return null;
1719
1720 final conversation = isOpenChat
1721 ? ref.read(conv.activeConversationProvider)
1722 : ref
1723 .read(conv.conversationsProvider)
1724 .value
1725 ?.where((c) => c.id == convId)
1726 .firstOrNull;
1727 if (conversation == null ||
1728 conversation.isTemporary ||
1729 conversation.title != 'New Chat') {
1730 return null;
1731 }
1732
1733 final messages = timeline ?? state.messages;
1734 final assistantCount = messages
1735 .where((m) => m.role == MessageRole.assistant)
1736 .length;
1737 if (assistantCount != 1) return null;
1738
1739 return _generateAndApplyTitle(convId);
1740 }
1741
1742 Future<void> _generateAndApplyTitle(String conversationId) {
1743 if (!_titleGenerationsInFlight.add(conversationId)) {
1744 return _titleGenerationFutures[conversationId] ?? Future<void>.value();
1745 }
1746 final future = () async {
1747 try {
1748 final title = await generateTitleWithAi(conversationId);
1749 if (!ref.mounted) return;
1750 if (title == null || title.isEmpty || title == 'New Chat') return;
1751
1752 await ref
1753 .read(conv.conversationsProvider.notifier)
1754 .renameConversation(conversationId, title);
1755 } finally {
1756 _titleGenerationsInFlight.remove(conversationId);
1757 _titleGenerationFutures.remove(conversationId);
1758 }
1759 }();
1760 _titleGenerationFutures[conversationId] = future;
1761 return future;
1762 }
1763
1764 Future<String?> generateTitleWithAi(String conversationId) async {
1765 final messages = await _loadMessagesForConversation(conversationId);
1766 if (!ref.mounted) return null;
1767 if (messages.isEmpty) return null;
1768
1769 final timeline = MessageVariants.resolveActiveTimeline(messages);
1770 if (timeline.isEmpty) return null;
1771
1772 final conversation = ref
1773 .read(conv.conversationsProvider)
1774 .value
1775 ?.where((c) => c.id == conversationId)
1776 .firstOrNull;
1777
1778 final server = _resolveServerForTitle(conversation);
1779 final modelId = _resolveModelIdForTitle(conversation);
1780 final titleService = ref.read(titleGenerationServiceProvider);
1781
1782 if (server == null || modelId == null) {
1783 return _fallbackTitleFromMessages(timeline, titleService);
1784 }
1785
1786 final chatService = _resolveChatServiceForTitle(server);
1787 if (chatService == null) {
1788 return _fallbackTitleFromMessages(timeline, titleService);
1789 }
1790
1791 final generated = await titleService.generateTitleWithLLM(
1792 chatService: chatService,
1793 server: server,
1794 modelId: modelId,
1795 messages: timeline,
1796 params: ref.read(chatParamsProvider),
1797 );
1798
1799 if (!ref.mounted) return null;
1800 if (generated != null && generated.isNotEmpty) {
1801 return generated;
1802 }
1803 return _fallbackTitleFromMessages(timeline, titleService);
1804 }
1805
1806 Future<List<Message>> _loadMessagesForConversation(
1807 String conversationId,
1808 ) async {
1809 if (_currentConversationId == conversationId &&
1810 state.allMessages.isNotEmpty) {
1811 return MessageVariants.resolveActiveTimeline(state.allMessages);
1812 }
1813
1814 final db = ref.read(databaseProvider);
1815 final loaded = await db.store.runInTransactionAsync(
1816 TxMode.read,
1817 _loadMessagesInBackground,
1818 conversationId,
1819 );
1820 return MessageVariants.resolveActiveTimeline(loaded);
1821 }
1822
1823 Server? _resolveServerForTitle(Conversation? conversation) {
1824 if (conversation?.serverId != null) {
1825 final servers = ref.read(serversProvider).value ?? [];
1826 for (final server in servers) {
1827 if (server.id == conversation!.serverId) {
1828 return server;
1829 }
1830 }
1831 }
1832 return ref.read(activeServerProvider);
1833 }
1834
1835 String? _resolveModelIdForTitle(Conversation? conversation) {
1836 return conversation?.modelId ??
1837 ref.read(activeChatTargetProvider).effectiveModelId;
1838 }
1839
1840 ChatService? _resolveChatServiceForTitle(Server server) {
1841 final active = ref.read(activeServerProvider);
1842 if (active?.id == server.id) {
1843 return ref.read(chatServiceProvider);
1844 }
1845
1846 return createChatServiceForServer(
1847 server: server,
1848 dio: ref.read(dioProvider),
1849 onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
1850 onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
1851 onDeviceMlxService: ref.read(onDeviceMlxServiceProvider),
1852 loadedOnDeviceRuntime: ref.read(
1853 onDeviceEngineProvider.select((s) => s.loadedRuntime),
1854 ),
1855 );
1856 }
1857
1858 String _fallbackTitleFromMessages(
1859 List<Message> messages,
1860 TitleGenerationService titleService,
1861 ) {
1862 final userMessage = messages
1863 .where((m) => m.role == MessageRole.user)
1864 .firstOrNull;
1865 if (userMessage == null || userMessage.content.trim().isEmpty) {
1866 return 'New Chat';
1867 }
1868 return titleService.truncateFirstMessageTitle(userMessage.content);
1869 }
1870
1871 void _maybeRequestReviewAfterSuccessfulCompletion({
1872 required Message finalMessage,
1873 required Server server,
1874 required ModelInfo? selectedModel,
1875 }) {
1876 final modelId =
1877 selectedModel?.id ??
1878 ref.read(activeChatTargetProvider).effectiveModelId;
1879
1880 unawaited(
1881 ref
1882 .read(reviewPromptServiceProvider)
1883 .maybeRequestReviewAfterSuccessfulChat(
1884 assistantContent: finalMessage.content,
1885 serverType: server.type,
1886 modelId: modelId,
1887 usedCustomPersona: _isUsingCustomPersona(),
1888 )
1889 .catchError((Object error, StackTrace stackTrace) {
1890 Log.error('Review prompt request failed: $error');
1891 return false;
1892 }),
1893 );
1894 }
1895
1896 bool _isUsingCustomPersona() {
1897 final activeConversation = ref.read(conv.activeConversationProvider);
1898 final personaId = activeConversation?.personaId;
1899 if (personaId == null) return false;
1900
1901 final personas = ref.read(personasNotifierProvider).value ?? [];
1902 return personas.any(
1903 (persona) => persona.id == personaId && !persona.isBuiltIn,
1904 );
1905 }
1906
1907 Future<void> cancelStream() async {
1908 final session = _activeSession;
1909 _clearPendingApproval();
1910 await session?.detach();
1911 if (!ref.mounted) return;
1912
1913 session?.chatService.cancelStream();
1914
1915 await _saveService?.flush();
1916 if (!ref.mounted) return;
1917
1918 final streamingMessage = session?.latestMessage ?? state.streamingMessage;
1919 if (streamingMessage != null) {
1920 final finalMessage = _finalizeStreamMessage(
1921 streamingMessage.copyWith(
1922 status: MessageStatus.cancelled,
1923 isProcessing: false,
1924 ),
1925 stopReason: 'cancelled',
1926 session: session,
1927 );
1928 await _saveMessage(finalMessage, persist: session?.persisted);
1929 if (!ref.mounted) return;
1930 _replaceMessageInState(finalMessage, clearStreaming: true);
1931 }
1932
1933 if (!ref.mounted) return;
1934 state = state.copyWith(isStreaming: false, clearStreaming: true);
1935 session?.latestMessage = null;
1936 if (session != null) _endSession(session);
1937 }
1938
1939 Future<void> retryLastMessage() async {
1940 if (_blockIfOnDeviceBusy()) return;
1941 final messages = state.messages;
1942 if (messages.isEmpty) return;
1943
1944 final lastAssistantIndex = messages.lastIndexWhere(
1945 (m) => m.role == MessageRole.assistant,
1946 );
1947 if (lastAssistantIndex < 0) return;
1948
1949 Message? userMessage;
1950 for (var i = lastAssistantIndex - 1; i >= 0; i--) {
1951 if (messages[i].role == MessageRole.user) {
1952 userMessage = messages[i];
1953 break;
1954 }
1955 }
1956 if (userMessage == null) return;
1957
1958 final userMessageIndex = messages.indexWhere(
1959 (m) => m.id == userMessage!.id,
1960 );
1961 final messagesToRemove = messages.sublist(userMessageIndex);
1962 final db = ref.read(databaseProvider);
1963
1964 for (final msg in messagesToRemove) {
1965 final query = db.messageBox
1966 .query(MessageEntity_.id.equals(msg.id))
1967 .build();
1968 db.messageBox.removeMany(query.findIds());
1969 query.close();
1970 }
1971
1972 final removedIds = messagesToRemove.map((m) => m.id).toSet();
1973 final updatedAll = state.allMessages
1974 .where((m) => !removedIds.contains(m.id))
1975 .toList();
1976 state = state.copyWith(
1977 allMessages: updatedAll,
1978 messages: messages.sublist(0, userMessageIndex),
1979 clearStreaming: true,
1980 );
1981
1982 await sendMessage(
1983 userMessage.content,
1984 attachments: userMessage.attachmentPaths?.map((p) => File(p)).toList(),
1985 );
1986 }
1987
1988 Future<void> continueFromMessage(String messageId) async {
1989 if (_blockIfOnDeviceBusy()) return;
1990 final messages = state.messages;
1991 if (messages.isEmpty || messages.last.id != messageId) return;
1992 final assistant = messages.last;
1993 if (assistant.role != MessageRole.assistant) return;
1994 if (state.isStreaming) return;
1995
1996 final target = ref.read(activeChatTargetProvider);
1997 final server = target.server;
1998 final effectiveModelId = target.effectiveModelId;
1999 if (server == null || effectiveModelId == null) return;
2000 final chatService = ref.read(chatServiceFactoryProvider)(server);
2001 if (chatService == null) return;
2002
2003 final streamingAssistant = assistant.copyWith(
2004 status: MessageStatus.streaming,
2005 isProcessing: true,
2006 );
2007 final updatedAll = state.allMessages.map((m) {
2008 return m.id == streamingAssistant.id ? streamingAssistant : m;
2009 }).toList();
2010 state = state.copyWith(
2011 allMessages: updatedAll,
2012 messages: MessageVariants.resolveActiveTimeline(updatedAll),
2013 isStreaming: true,
2014 streamingMessage: streamingAssistant,
2015 clearError: true,
2016 );
2017 final session = _beginSession(
2018 streamingAssistant.conversationId,
2019 server,
2020 chatService,
2021 );
2022 session.resetCheckpointMetrics();
2023 session.updateSavedMetrics(streamingAssistant);
2024 session.latestMessage = streamingAssistant;
2025
2026 await _runAssistantStream(
2027 session,
2028 streamingAssistant,
2029 target.selectedModel,
2030 effectiveModelId: effectiveModelId,
2031 continueGeneration: true,
2032 );
2033 }
2034
2035 Future<void> cycleMessageVariant(String messageId, int direction) async {
2036 Message? displayMsg;
2037 for (final m in state.messages) {
2038 if (m.id == messageId) {
2039 displayMsg = m;
2040 break;
2041 }
2042 }
2043 if (displayMsg == null) return;
2044
2045 final variants = MessageVariants.variantsForMessage(
2046 state.allMessages,
2047 displayMsg,
2048 );
2049 if (variants.length <= 1) return;
2050
2051 final currentIndex = MessageVariants.activeVariantIndex(variants);
2052 final newIndex = (currentIndex + direction).clamp(0, variants.length - 1);
2053 if (newIndex == currentIndex) return;
2054
2055 final groupId = MessageVariants.groupId(displayMsg);
2056 final targetId = variants[newIndex].id;
2057 final updatedAll = state.allMessages.map((message) {
2058 if (MessageVariants.groupId(message) != groupId) return message;
2059 return message.copyWith(isActiveVariant: message.id == targetId);
2060 }).toList();
2061
2062 for (final message in updatedAll.where(
2063 (m) => MessageVariants.groupId(m) == groupId,
2064 )) {
2065 await _saveMessage(message);
2066 }
2067 _setAllMessages(updatedAll);
2068 }
2069
2070 int _nextVariantIndex(String groupId) {
2071 final variants = state.allMessages.where(
2072 (m) => MessageVariants.groupId(m) == groupId,
2073 );
2074 if (variants.isEmpty) return 0;
2075 return variants.map((m) => m.variantIndex).reduce((a, b) => a > b ? a : b) +
2076 1;
2077 }
2078
2079 Future<void> retryMessage(String messageId) async {
2080 if (_blockIfOnDeviceBusy()) return;
2081 if (state.isStreaming || _isRegenerating) return;
2082 _isRegenerating = true;
2083 try {
2084 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2085 if (messageIndex == -1) return;
2086
2087 final message = state.messages[messageIndex];
2088 if (message.role != MessageRole.assistant) return;
2089
2090 if (messageIndex > 0 &&
2091 state.messages[messageIndex - 1].role == MessageRole.user) {
2092 final userMessage = state.messages[messageIndex - 1];
2093 final groupId = MessageVariants.groupId(message);
2094
2095 final deactivated = state.allMessages.map((m) {
2096 if (MessageVariants.groupId(m) != groupId) return m;
2097 return m.copyWith(isActiveVariant: false);
2098 }).toList();
2099 for (final m in deactivated.where(
2100 (x) => MessageVariants.groupId(x) == groupId,
2101 )) {
2102 await _saveMessage(m);
2103 }
2104 state = state.copyWith(allMessages: deactivated);
2105
2106 await _regenerateAssistant(
2107 userMessage,
2108 variantGroupId: groupId,
2109 threadOrder: message.threadOrder,
2110 variantIndex: _nextVariantIndex(groupId),
2111 );
2112 }
2113 } finally {
2114 _isRegenerating = false;
2115 }
2116 }
2117
2118 Future<void> _regenerateAssistant(
2119 Message userMessage, {
2120 required String variantGroupId,
2121 required int threadOrder,
2122 required int variantIndex,
2123 }) async {
2124 if (_blockIfOnDeviceBusy()) return;
2125 final target = ref.read(activeChatTargetProvider);
2126 final selectedModel = target.selectedModel;
2127 final server = target.server;
2128 final effectiveModelId = target.effectiveModelId;
2129 if (server == null ||
2130 effectiveModelId == null ||
2131 _activeConversationId == null) {
2132 return;
2133 }
2134
2135 await _abortStreamImmediately();
2136 if (!ref.mounted) return;
2137
2138 final assistantMessage = Message(
2139 id: generateUuid(),
2140 conversationId: _activeConversationId!,
2141 role: MessageRole.assistant,
2142 content: '',
2143 createdAt: DateTime.now(),
2144 status: MessageStatus.streaming,
2145 modelId: effectiveModelId,
2146 variantGroupId: variantGroupId,
2147 variantIndex: variantIndex,
2148 threadOrder: threadOrder,
2149 isActiveVariant: true,
2150 parentMessageId: userMessage.id,
2151 );
2152
2153 final updatedAll = [...state.allMessages, assistantMessage];
2154 state = state.copyWith(
2155 allMessages: updatedAll,
2156 messages: MessageVariants.resolveActiveTimeline(updatedAll),
2157 isStreaming: true,
2158 streamingMessage: assistantMessage,
2159 clearError: true,
2160 );
2161 final chatService = ref.read(chatServiceFactoryProvider)(server);
2162 if (chatService == null) {
2163 state = state.copyWith(
2164 isStreaming: false,
2165 errorMessage: 'No chat service available',
2166 clearStreaming: true,
2167 );
2168 return;
2169 }
2170 final session = _beginSession(
2171 assistantMessage.conversationId,
2172 server,
2173 chatService,
2174 );
2175 await _saveMessage(assistantMessage, persist: session.persisted);
2176
2177 session.resetCheckpointMetrics();
2178 session.updateSavedMetrics(assistantMessage);
2179
2180 await _runAssistantStream(
2181 session,
2182 assistantMessage,
2183 selectedModel,
2184 effectiveModelId: effectiveModelId,
2185 );
2186 }
2187
2188 /// After successful tool execution (issue #77), build `tool` role messages
2189 /// for each completed tool event, persist them, and re-issue the chat
2190 /// completion so the model can produce the final answer.
2191 ///
2192 /// The assistant message emitted in the first turn has its `toolCalls`
2193 /// field populated with the executed results, plus matching `MessageRole.tool`
2194 /// messages are appended right after it so the OpenAI-compatible protocol
2195 /// gets the `tool_call_id` ↔ `tool_calls[].id` pairing the model needs to
2196 /// continue the turn.
2197 Future<void> _sendFollowupWithToolResults({
2198 required Message previousAssistant,
2199 required List<ToolEvent> toolEvents,
2200 required Server server,
2201 required ModelInfo? selectedModel,
2202 required String effectiveModelId,
2203 required ChatService chatService,
2204 required ChatParameters chatParams,
2205 required List<ToolDefinition> tools,
2206 required List<McpIntegration>? integrations,
2207 required String streamConvId,
2208 required bool isCurrentContext,
2209 }) async {
2210 if (!ref.mounted) return;
2211
2212 final completed = toolEvents
2213 .where((e) => e.status == ToolEventStatus.completed)
2214 .toList();
2215 if (completed.isEmpty) return;
2216
2217 // Synthesize stable tool-call IDs that match between the assistant
2218 // message's `tool_calls[].id` and the tool role message's `tool_call_id`.
2219 // The streaming-side `ToolCallData` doesn't carry an OpenAI `id`, so we
2220 // mint one here and reuse it on both sides.
2221 String toolCallIdFor(ToolEvent event) {
2222 final uniqueSuffix = event.eventId.isNotEmpty
2223 ? event.eventId
2224 : '${event.toolName}_${event.timestamp.microsecondsSinceEpoch}';
2225 return 'call_${uniqueSuffix}_completed';
2226 }
2227
2228 final toolCallEntries = completed
2229 .map(
2230 (e) => msg_model.ToolCallData(
2231 id: toolCallIdFor(e),
2232 toolName: e.toolName,
2233 arguments: e.arguments ?? const {},
2234 result: e.result,
2235 ),
2236 )
2237 .toList();
2238
2239 final assistantWithToolCalls = previousAssistant.copyWith(
2240 toolCalls: toolCallEntries,
2241 stopReason: 'tool_use',
2242 );
2243 await _saveMessage(assistantWithToolCalls);
2244 if (!ref.mounted) return;
2245 if (isCurrentContext) {
2246 _replaceMessageInAll(assistantWithToolCalls, clearStreaming: true);
2247 }
2248
2249 final toolMessages = completed.map((e) {
2250 final callId = toolCallIdFor(e);
2251 return Message(
2252 id: 'tool_${callId}_${e.timestamp.microsecondsSinceEpoch}',
2253 conversationId: previousAssistant.conversationId,
2254 role: MessageRole.tool,
2255 content: e.result ?? e.error ?? '',
2256 createdAt: e.timestamp,
2257 status: MessageStatus.complete,
2258 toolCallId: callId,
2259 toolSessionId: previousAssistant.toolSessionId,
2260 parentMessageId: previousAssistant.id,
2261 threadOrder: previousAssistant.threadOrder + 0,
2262 variantGroupId: previousAssistant.variantGroupId,
2263 variantIndex: previousAssistant.variantIndex,
2264 );
2265 }).toList();
2266
2267 for (final tm in toolMessages) {
2268 await _saveMessage(tm);
2269 }
2270
2271 if (!ref.mounted) return;
2272
2273 // Persist the assistant+tool messages into state.allMessages so future
2274 // `_buildMessagesForApi(selectedModel)` calls (used by the follow-up
2275 // stream) include them. Without this the next request would be missing
2276 // the tool history and the model would re-emit the same tool call.
2277 final newAll = [...state.allMessages, ...toolMessages];
2278 final activeTimeline = MessageVariants.resolveActiveTimeline(newAll);
2279 state = state.copyWith(allMessages: newAll, messages: activeTimeline);
2280
2281 // Create a new assistant message that will receive the model's final
2282 // answer. It shares the variant group with the previous assistant turn
2283 // so users see a single grouped assistant card that contains both the
2284 // tool-call turn and the follow-up answer.
2285 final continuationThreadOrder = MessageVariants.nextThreadOrder(
2286 state.allMessages,
2287 );
2288 final continuationMessage = Message(
2289 id: generateUuid(),
2290 conversationId: previousAssistant.conversationId,
2291 role: MessageRole.assistant,
2292 content: '',
2293 createdAt: DateTime.now(),
2294 status: MessageStatus.streaming,
2295 modelId: effectiveModelId,
2296 variantGroupId: previousAssistant.variantGroupId,
2297 variantIndex: previousAssistant.variantIndex + 1,
2298 threadOrder: continuationThreadOrder,
2299 isActiveVariant: true,
2300 parentMessageId: previousAssistant.id,
2301 );
2302
2303 final newAllWithAssistant = [...newAll, continuationMessage];
2304 state = state.copyWith(
2305 allMessages: newAllWithAssistant,
2306 messages: MessageVariants.resolveActiveTimeline(newAllWithAssistant),
2307 isStreaming: true,
2308 streamingMessage: continuationMessage,
2309 clearStreaming: false,
2310 );
2311 final session = _beginSession(
2312 continuationMessage.conversationId,
2313 server,
2314 chatService,
2315 );
2316 await _saveMessage(continuationMessage, persist: session.persisted);
2317 if (!ref.mounted) return;
2318
2319 // Reuse the streaming logic — `_runAssistantStream` builds the API
2320 // message list from state, which now contains the tool history, and
2321 // will issue a fresh chat completion. If the model emits another tool
2322 // call, the same onDone flow will execute it again until the model
2323 // yields a final answer or `maxIterations` (enforced inside
2324 // `ToolExecutionLoop`) is reached.
2325 await _runAssistantStream(
2326 session,
2327 continuationMessage,
2328 selectedModel,
2329 effectiveModelId: effectiveModelId,
2330 );
2331 }
2332
2333 Future<void> _runAssistantStream(
2334 GenerationSession session,
2335 Message assistantMessage,
2336 ModelInfo? selectedModel, {
2337 required String effectiveModelId,
2338 bool continueGeneration = false,
2339 }) async {
2340 final server = session.server;
2341 final chatService = session.chatService;
2342 final chatParams = ref.read(chatParamsProvider);
2343
2344 final messagesForApi = continueGeneration
2345 ? _buildMessagesForContinue(selectedModel, assistantMessage)
2346 : _buildMessagesForApi(selectedModel);
2347
2348 try {
2349 session.resetStreamMetrics();
2350
2351 String reasoningContent = '';
2352 var streamingAssistantMessage = assistantMessage;
2353 session.latestMessage = streamingAssistantMessage;
2354 var isFirstContinueChunk = continueGeneration;
2355
2356 final mcpConfig = ref.read(chatMcpConfigProvider);
2357 final activeIntegrations = mcpConfig.integrations
2358 .where((i) => i.enabled)
2359 .toList();
2360 final integrations = mcpConfig.enabled && activeIntegrations.isNotEmpty
2361 ? activeIntegrations
2362 : null;
2363
2364 final registry = ref.read(toolRegistryProvider);
2365 final tools = mcpConfig.enabled
2366 ? await registry.listTools()
2367 : const <ToolDefinition>[];
2368
2369 bool stateNeedsUpdate = false;
2370 bool streamHadError = false;
2371
2372 void updateUiState() {
2373 if (!stateNeedsUpdate) return;
2374 stateNeedsUpdate = false;
2375 if (_activeConversationId == assistantMessage.conversationId) {
2376 state = state.copyWith(streamingMessage: streamingAssistantMessage);
2377 }
2378 }
2379
2380 if (session.cancelled) return;
2381
2382 session.uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (
2383 _,
2384 ) {
2385 updateUiState();
2386 });
2387
2388 session.subscription = chatService
2389 .sendMessage(
2390 server: server,
2391 modelId: effectiveModelId,
2392 messages: messagesForApi,
2393 params: chatParams,
2394 integrations: integrations,
2395 tools: tools,
2396 continueGeneration: continueGeneration,
2397 )
2398 .listen(
2399 (response) async {
2400 if (session.cancelled || !ref.mounted) return;
2401 final streamConvId = assistantMessage.conversationId;
2402 final isCurrentContext = _activeConversationId == streamConvId;
2403
2404 switch (response.type) {
2405 case ChatResponseType.message:
2406 if (response.content?.isNotEmpty ?? false) {
2407 session.noteFirstToken();
2408 }
2409 var delta = response.content ?? '';
2410 if (isFirstContinueChunk && delta.isNotEmpty) {
2411 isFirstContinueChunk = false;
2412 final existing = streamingAssistantMessage.content;
2413 if (existing.isNotEmpty &&
2414 !RegExp(
2415 r'[\s\p{P}]$',
2416 unicode: true,
2417 ).hasMatch(existing) &&
2418 !RegExp(r'^[\s\p{P}]', unicode: true).hasMatch(delta)) {
2419 delta = ' $delta';
2420 }
2421 }
2422 streamingAssistantMessage = streamingAssistantMessage
2423 .copyWith(
2424 content: streamingAssistantMessage.content + delta,
2425 isProcessing: false,
2426 );
2427 session.latestMessage = streamingAssistantMessage;
2428 session.chunkCount++;
2429 if (session.persisted &&
2430 session.shouldCheckpointSave(streamingAssistantMessage)) {
2431 _saveService?.enqueue(streamingAssistantMessage);
2432 session.updateSavedMetrics(streamingAssistantMessage);
2433 session.resetCheckpointMetrics();
2434 }
2435 stateNeedsUpdate = true;
2436 break;
2437 case ChatResponseType.reasoning:
2438 if (response.reasoningContent?.isNotEmpty ?? false) {
2439 session.noteFirstToken();
2440 }
2441 reasoningContent += response.reasoningContent ?? '';
2442 streamingAssistantMessage = streamingAssistantMessage
2443 .copyWith(
2444 reasoningContent: reasoningContent,
2445 isProcessing: false,
2446 );
2447 session.latestMessage = streamingAssistantMessage;
2448 session.chunkCount++;
2449 if (session.persisted &&
2450 session.shouldCheckpointSave(streamingAssistantMessage)) {
2451 _saveService?.enqueue(streamingAssistantMessage);
2452 session.updateSavedMetrics(streamingAssistantMessage);
2453 session.resetCheckpointMetrics();
2454 }
2455 stateNeedsUpdate = true;
2456 break;
2457 case ChatResponseType.processing:
2458 streamingAssistantMessage = streamingAssistantMessage
2459 .copyWith(isProcessing: true);
2460 session.latestMessage = streamingAssistantMessage;
2461 stateNeedsUpdate = true;
2462 break;
2463 case ChatResponseType.timeoutError:
2464 case ChatResponseType.error:
2465 streamHadError = true;
2466 session.cancelUiTimer();
2467 streamingAssistantMessage = _finalizeStreamMessage(
2468 streamingAssistantMessage.copyWith(
2469 status: MessageStatus.error,
2470 errorMessage: response.content,
2471 isProcessing: false,
2472 ),
2473 stopReason: 'error',
2474 session: session,
2475 );
2476 await _saveMessage(
2477 streamingAssistantMessage,
2478 persist: session.persisted,
2479 );
2480 if (ref.mounted) {
2481 if (isCurrentContext) {
2482 _replaceMessageInAll(
2483 streamingAssistantMessage,
2484 clearStreaming: true,
2485 );
2486 state = state.copyWith(
2487 isStreaming: false,
2488 clearStreaming: true,
2489 );
2490 }
2491 _endSession(session);
2492 }
2493 break;
2494 case ChatResponseType.done:
2495 if (response.stats != null) {
2496 session.stats = response.stats;
2497 }
2498 break;
2499 default:
2500 break;
2501 }
2502 },
2503 onDone: () async {
2504 session.cancelUiTimer();
2505 if (streamHadError) {
2506 // The error branch already finalised the message and
2507 // stopped streaming — do not overwrite the error state
2508 // with a successful complete message.
2509 return;
2510 }
2511 final streamConvId = assistantMessage.conversationId;
2512 final isCurrentContext = _activeConversationId == streamConvId;
2513
2514 final finalMessage = _finalizeStreamMessage(
2515 streamingAssistantMessage.copyWith(
2516 status: MessageStatus.complete,
2517 isProcessing: false,
2518 ),
2519 stopReason: 'complete',
2520 session: session,
2521 );
2522 await _saveMessage(finalMessage, persist: session.persisted);
2523 if (!ref.mounted) return;
2524 if (isCurrentContext) {
2525 _replaceMessageInAll(finalMessage, clearStreaming: true);
2526 state = state.copyWith(
2527 isStreaming: false,
2528 clearStreaming: true,
2529 );
2530 _maybeAutoGenerateTitleAfterFirstReply();
2531 }
2532 if (ref.mounted) {
2533 _endSession(session);
2534 }
2535 await _syncConversationStatsAfterGeneration(
2536 streamConvId,
2537 finalMessage,
2538 isCurrentContext: isCurrentContext,
2539 );
2540 if (!ref.mounted) return;
2541 _maybeRequestReviewAfterSuccessfulCompletion(
2542 finalMessage: finalMessage,
2543 server: server,
2544 selectedModel: selectedModel,
2545 );
2546
2547 // Auto-speak: read the response aloud if the setting is
2548 // enabled and voice mode is not already handling TTS.
2549 if (ref.mounted) {
2550 final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
2551 final voiceActive =
2552 ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
2553 // Don't read a background reply aloud over another chat.
2554 if (autoSpeak &&
2555 !voiceActive &&
2556 _activeConversationId == finalMessage.conversationId &&
2557 finalMessage.content.trim().isNotEmpty) {
2558 ref
2559 .read(ttsProvider.notifier)
2560 .speak(
2561 finalMessage.content,
2562 messageId: finalMessage.id,
2563 conversationId: finalMessage.conversationId,
2564 );
2565 }
2566 }
2567 },
2568 onError: (Object error, StackTrace stackTrace) async {
2569 Log.error('Stream error: $error');
2570 session.cancelUiTimer();
2571 final isCurrentContext =
2572 _activeConversationId == assistantMessage.conversationId;
2573 final errorMessage = streamingAssistantMessage.copyWith(
2574 status: MessageStatus.error,
2575 errorMessage: error.toString(),
2576 isProcessing: false,
2577 );
2578 await _saveMessage(errorMessage, persist: session.persisted);
2579 if (!ref.mounted) return;
2580 if (isCurrentContext) {
2581 _replaceMessageInAll(errorMessage, clearStreaming: true);
2582 state = state.copyWith(
2583 isStreaming: false,
2584 clearStreaming: true,
2585 errorMessage: error.toString(),
2586 );
2587 }
2588 if (ref.mounted) {
2589 _endSession(session);
2590 }
2591 },
2592 );
2593 } catch (e) {
2594 if (!ref.mounted) return;
2595 state = state.copyWith(
2596 isStreaming: false,
2597 errorMessage: e.toString(),
2598 clearStreaming: true,
2599 );
2600 _endSession(session);
2601 }
2602 }
2603
2604 void _replaceMessageInAll(Message message, {bool clearStreaming = false}) {
2605 if (!ref.mounted) return;
2606 final updatedAll = state.allMessages.map((m) {
2607 return m.id == message.id ? message : m;
2608 }).toList();
2609 state = state.copyWith(
2610 allMessages: updatedAll,
2611 messages: MessageVariants.resolveActiveTimeline(updatedAll),
2612 streamingMessage: clearStreaming ? null : message,
2613 clearStreaming: clearStreaming,
2614 );
2615 }
2616
2617 Future<void> editAssistantMessage(String messageId, String newContent) async {
2618 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2619 if (messageIndex == -1) return;
2620
2621 final message = state.messages[messageIndex];
2622 if (message.role != MessageRole.assistant) return;
2623 if (newContent.trim().isEmpty) return;
2624 if (newContent == message.content) return;
2625
2626 final updated = message.copyWith(
2627 content: newContent,
2628 status: MessageStatus.complete,
2629 );
2630 await _saveMessage(updated);
2631 _replaceMessageInAll(updated);
2632 }
2633
2634 Future<void> branchFromMessage(String messageId) async {
2635 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2636 if (messageIndex == -1) return;
2637
2638 final activeConv = ref.read(conv.activeConversationProvider);
2639 if (activeConv == null) return;
2640
2641 final messagesToCopy = state.messages.sublist(0, messageIndex + 1);
2642 final branchTitle = activeConv.title.length > 40
2643 ? '${activeConv.title.substring(0, 40)}...'
2644 : activeConv.title;
2645
2646 final newConversation = await ref
2647 .read(conv.conversationsProvider.notifier)
2648 .createConversation(
2649 title: '$branchTitle (branch)',
2650 personaId: activeConv.personaId,
2651 systemPrompt: activeConv.systemPrompt,
2652 serverId: activeConv.serverId,
2653 modelId: activeConv.modelId,
2654 mcpEnabled: activeConv.mcpEnabled,
2655 );
2656
2657 if (activeConv.temperature != null ||
2658 activeConv.topP != null ||
2659 activeConv.maxTokens != null ||
2660 activeConv.contextLength != null) {
2661 await ref
2662 .read(conv.conversationsProvider.notifier)
2663 .updateChatParams(
2664 newConversation.id,
2665 temperature: activeConv.temperature,
2666 topP: activeConv.topP,
2667 maxTokens: activeConv.maxTokens,
2668 contextLength: activeConv.contextLength,
2669 );
2670 }
2671
2672 for (final msg in messagesToCopy) {
2673 final copied = msg.copyWith(
2674 id: generateUuid(),
2675 conversationId: newConversation.id,
2676 );
2677 await _saveMessage(copied);
2678 }
2679
2680 final lastMessage = messagesToCopy.last;
2681 final preview = lastMessage.content.length > 100
2682 ? '${lastMessage.content.substring(0, 100)}...'
2683 : lastMessage.content;
2684
2685 await ref
2686 .read(conv.conversationsProvider.notifier)
2687 .updatePreview(
2688 newConversation.id,
2689 preview,
2690 DateTime.now(),
2691 messageCount: messagesToCopy.length,
2692 characterCount: messagesToCopy.fold<int>(
2693 0,
2694 (sum, message) => sum + message.content.length,
2695 ),
2696 );
2697
2698 final refreshedConversations = ref.read(conv.conversationsProvider).value;
2699 final branchedConversation = refreshedConversations?.firstWhere(
2700 (c) => c.id == newConversation.id,
2701 orElse: () => newConversation,
2702 );
2703
2704 if (branchedConversation != null) {
2705 await loadConversation(branchedConversation);
2706 }
2707 }
2708
2709 Future<void> deleteMessage(String messageId) async {
2710 final db = ref.read(databaseProvider);
2711 final query = db.messageBox
2712 .query(MessageEntity_.id.equals(messageId))
2713 .build();
2714 db.messageBox.removeMany(query.findIds());
2715 query.close();
2716
2717 final updatedAll = state.allMessages
2718 .where((m) => m.id != messageId)
2719 .toList();
2720 state = state.copyWith(
2721 allMessages: updatedAll,
2722 messages: MessageVariants.resolveActiveTimeline(updatedAll),
2723 );
2724
2725 if (_currentConversationId != null) {
2726 _recomputeConversationTotal(_currentConversationId!);
2727 }
2728 }
2729
2730 Future<void> editMessageSaveOnly(String messageId, String newContent) async {
2731 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2732 if (messageIndex == -1) return;
2733
2734 final message = state.messages[messageIndex];
2735 if (message.role != MessageRole.user) return;
2736 if (newContent.trim().isEmpty) return;
2737 if (newContent == message.content) return;
2738
2739 final updated = message.copyWith(content: newContent);
2740 await _saveMessage(updated);
2741 _replaceMessageInAll(updated);
2742 }
2743
2744 Future<void> generateResponseForLastUser() async {
2745 if (_blockIfOnDeviceBusy()) return;
2746 if (state.isStreaming || _isRegenerating) return;
2747 _isRegenerating = true;
2748 try {
2749 final messages = state.messages;
2750 if (messages.isEmpty || messages.last.role != MessageRole.user) return;
2751
2752 final userMessage = messages.last;
2753 await _regenerateAssistant(
2754 userMessage,
2755 variantGroupId: generateUuid(),
2756 threadOrder: userMessage.threadOrder + 1,
2757 variantIndex: 0,
2758 );
2759 } finally {
2760 _isRegenerating = false;
2761 }
2762 }
2763
2764 Future<void> generateAiUserMessage() async {
2765 if (_blockIfOnDeviceBusy()) return;
2766 if (state.isStreaming) return;
2767 if (state.messages.isEmpty) return;
2768
2769 final target = ref.read(activeChatTargetProvider);
2770 final server = target.server;
2771 final modelId = target.effectiveModelId;
2772 final chatService = ref.read(chatServiceProvider);
2773 if (server == null || chatService == null || modelId == null) return;
2774
2775 final generated = await ref
2776 .read(smartReplyServiceProvider)
2777 .generateUserMessage(
2778 chatService: chatService,
2779 server: server,
2780 modelId: modelId,
2781 messages: state.messages,
2782 params: ref.read(chatParamsProvider),
2783 );
2784
2785 if (generated == null || generated.trim().isEmpty) return;
2786 await sendMessage(generated.trim());
2787 }
2788
2789 Future<void> saveTemporaryChatToHistory() async {
2790 if (!state.isTemporary || state.messages.isEmpty) return;
2791
2792 final server = ref.read(activeServerProvider);
2793 if (server == null) return;
2794
2795 final settings = ref.read(settingsProvider);
2796 final firstUser = state.messages
2797 .where((m) => m.role == MessageRole.user)
2798 .map((m) => m.content.trim())
2799 .firstWhere((c) => c.isNotEmpty, orElse: () => '');
2800
2801 final title = settings.autoGenerateTitle
2802 ? 'New Chat'
2803 : ref
2804 .read(titleGenerationServiceProvider)
2805 .truncateFirstMessageTitle(
2806 firstUser.isNotEmpty ? firstUser : 'New Chat',
2807 );
2808
2809 final preselected = ref.read(selectedPersonasProvider);
2810 final conversation = await ref
2811 .read(conv.conversationsProvider.notifier)
2812 .createConversation(
2813 title: title,
2814 serverId: server.id,
2815 modelId: ref.read(selectedModelProvider)?.id,
2816 personaId: preselected.isEmpty
2817 ? null
2818 : PersonaPromptUtils.joinPersonaIds(
2819 preselected.map((p) => p.id).toList(),
2820 ),
2821 systemPrompt: preselected.isEmpty
2822 ? null
2823 : PersonaPromptUtils.combineSystemPrompts(preselected),
2824 mcpEnabled: settings.mcpEnabled && settings.newChatMcpEnabled,
2825 isTemporary: false,
2826 );
2827
2828 _currentConversationId = conversation.id;
2829 _ephemeralConversationId = null;
2830 state = state.copyWith(isTemporary: false);
2831 ref
2832 .read(conv.activeConversationIdProvider.notifier)
2833 .setActiveConversation(conversation);
2834
2835 final updatedAll = state.allMessages
2836 .map((m) => m.copyWith(conversationId: conversation.id))
2837 .toList();
2838 _setAllMessages(updatedAll);
2839
2840 for (final message in updatedAll) {
2841 await _saveMessage(message);
2842 }
2843 }
2844
2845 Future<void> editMessage(String messageId, String newContent) async {
2846 if (_blockIfOnDeviceBusy()) return;
2847 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2848 if (messageIndex == -1) return;
2849
2850 final message = state.messages[messageIndex];
2851 if (message.role != MessageRole.user) return;
2852 if (newContent.trim().isEmpty) return;
2853 if (newContent == message.content) return;
2854
2855 final groupId = MessageVariants.groupId(message);
2856 final deactivated = state.allMessages.map((m) {
2857 if (MessageVariants.groupId(m) != groupId) return m;
2858 return m.copyWith(isActiveVariant: false);
2859 }).toList();
2860 for (final m in deactivated.where(
2861 (x) => MessageVariants.groupId(x) == groupId,
2862 )) {
2863 await _saveMessage(m);
2864 }
2865 state = state.copyWith(allMessages: deactivated);
2866
2867 final newUser = message.copyWith(
2868 id: generateUuid(),
2869 content: newContent,
2870 createdAt: DateTime.now(),
2871 variantIndex: _nextVariantIndex(groupId),
2872 isActiveVariant: true,
2873 parentMessageId: message.parentMessageId,
2874 );
2875 await _saveMessage(newUser);
2876 final updatedAll = [...state.allMessages, newUser];
2877 _setAllMessages(updatedAll);
2878
2879 await _regenerateAssistant(
2880 newUser,
2881 variantGroupId: generateUuid(),
2882 threadOrder: message.threadOrder + 1,
2883 variantIndex: 0,
2884 );
2885 }
2886
2887 /// Saves [message] unless it belongs to an in-memory (temporary) chat.
2888 /// A reply's own saves pass [persist] from its [GenerationSession], because
2889 /// the open chat may no longer be the chat the reply belongs to.
2890 Future<void> _saveMessage(Message message, {bool? persist}) async {
2891 // Check `ref.mounted` before touching `state` (via `_isInMemoryChat`).
2892 // On a disposed notifier, accessing `state` throws "Cannot use the Ref of
2893 // NotifierProvider ... after it has been disposed" — see issue #73.
2894 if (!ref.mounted) return;
2895 if (persist == false) return;
2896 if (persist == null && _isInMemoryChat) return;
2897 await persistMessage(message);
2898 }
2899
2900 /// Reads a conversation's messages from ObjectBox. Overridden in tests.
2901 @protected
2902 @visibleForTesting
2903 Future<List<Message>> loadConversationMessages(String conversationId) {
2904 final db = ref.read(databaseProvider);
2905 return db.store.runInTransactionAsync(
2906 TxMode.read,
2907 _loadMessagesInBackground,
2908 conversationId,
2909 );
2910 }
2911
2912 /// Writes a message to ObjectBox. Overridden in tests.
2913 @protected
2914 @visibleForTesting
2915 Future<void> persistMessage(Message message) async {
2916 final db = ref.read(databaseProvider);
2917 await db.store.runInTransactionAsync(
2918 TxMode.write,
2919 _saveMessageInBackground,
2920 message,
2921 );
2922 }
2923
2924 /// Test-only hook that exposes [_saveMessage] so we can verify it is safe
2925 /// to call on a disposed notifier (issue #73).
2926 @visibleForTesting
2927 Future<void> debugSaveMessageForTest(Message message) =>
2928 _saveMessage(message);
2929
2930 static void _saveMessageInBackground(Store store, Message message) {
2931 final box = store.box<MessageEntity>();
2932 final convBox = store.box<ConversationEntity>();
2933
2934 final query = box.query(MessageEntity_.id.equals(message.id)).build();
2935 final existing = query.findFirst();
2936 query.close();
2937
2938 final entity = MessageEntity.fromDomain(message);
2939 if (existing != null) {
2940 entity.internalId = existing.internalId;
2941 }
2942
2943 final convQuery = convBox
2944 .query(ConversationEntity_.id.equals(message.conversationId))
2945 .build();
2946 final convEntity = convQuery.findFirst();
2947 convQuery.close();
2948
2949 if (convEntity != null) {
2950 entity.conversation.target = convEntity;
2951 entity.conversationUid = convEntity.id;
2952 } else {
2953 entity.conversationUid = message.conversationId;
2954 }
2955
2956 box.put(entity);
2957 }
2958
2959 Future<void> clearConversation() async {
2960 await _saveService?.flush();
2961 if (!ref.mounted) return;
2962 final db = ref.read(databaseProvider);
2963 if (_currentConversationId != null && !_isInMemoryChat) {
2964 final query = db.messageBox
2965 .query(MessageEntity_.conversationUid.equals(_currentConversationId!))
2966 .build();
2967 db.messageBox.removeMany(query.findIds());
2968 query.close();
2969 }
2970 _currentConversationId = null;
2971 _ephemeralConversationId = null;
2972 _pendingTemporaryChat = false;
2973 state = const ChatState();
2974 ref
2975 .read(conv.activeConversationIdProvider.notifier)
2976 .setActiveConversation(null);
2977 }
2978
2979 void clearError() {
2980 state = state.copyWith(clearError: true);
2981 }
2982 }
2983
2984 final hasActiveChatSessionProvider = Provider<bool>((ref) {
2985 final chat = ref.watch(chatProvider);
2986 final activeConv = ref.watch(conv.activeConversationProvider);
2987 return chat.messages.isNotEmpty || chat.isStreaming || activeConv != null;
2988 });