10 files changed
+119
-135
lib/features/chat/providers/chat_notifier.dart
+118
-122
@@ -2,7 +2,6 @@ import 'dart:async';
2
import 'dart:io';
3
import 'dart:math';
4
5
-import 'package:dio/dio.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:localmind/core/logger/app_logger.dart';
7
import 'package:localmind/core/models/enums.dart';
@@ -22,7 +21,6 @@ import 'package:localmind/features/servers/data/models/server.dart';
21
import 'package:localmind/features/servers/providers/server_providers.dart';
22
import 'package:localmind/objectbox.g.dart';
23
import '../data/chat_service.dart';
25
-import '../data/models/chat_parameters.dart';
24
import '../data/models/message.dart' hide ToolCallData;
25
import '../data/tools/tool_definition.dart';
26
import '../data/tools/tool_execution_loop.dart';
@@ -559,141 +557,139 @@ class ChatNotifier extends Notifier<ChatState> {
557
final streamConvId = assistantMessage.conversationId;
558
final isCurrentContext = _currentConversationId == streamConvId;
559
final streamingMessage = streamingAssistantMessage;
562
- if (streamingMessage != null) {
563
- final hasContent =
564
- streamingMessage.content.isNotEmpty ||
565
- (streamingMessage.reasoningContent?.isNotEmpty ??
566
- false) ||
567
- collectedToolCalls.isNotEmpty;
568
-
569
- if (!hasContent) {
570
- final errorMessage = streamingMessage.copyWith(
571
- status: MessageStatus.error,
572
- errorMessage:
573
- 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
574
- isProcessing: false,
560
+ final hasContent =
561
+ streamingMessage.content.isNotEmpty ||
562
+ (streamingMessage.reasoningContent?.isNotEmpty ??
563
+ false) ||
564
+ collectedToolCalls.isNotEmpty;
565
+
566
+ if (!hasContent) {
567
+ final errorMessage = streamingMessage.copyWith(
568
+ status: MessageStatus.error,
569
+ errorMessage:
570
+ 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
571
+ isProcessing: false,
572
+ );
573
+ await _saveMessage(errorMessage);
574
+ if (isCurrentContext) {
575
+ _replaceMessageInState(
576
+ errorMessage,
577
+ clearStreaming: true,
578
);
576
- await _saveMessage(errorMessage);
577
- if (isCurrentContext) {
578
- _replaceMessageInState(
579
- errorMessage,
580
- clearStreaming: true,
581
- );
582
- state = state.copyWith(
583
- isStreaming: false,
584
- errorMessage: errorMessage.errorMessage,
585
- );
586
- }
587
- ref.read(isStreamingProvider.notifier).setStreaming(false);
588
- _latestStreamingMessage = null;
589
- } else {
590
- var finalMessage = streamingMessage.copyWith(
591
- status: MessageStatus.complete,
592
- isProcessing: false,
579
+ state = state.copyWith(
580
+ isStreaming: false,
581
+ errorMessage: errorMessage.errorMessage,
582
);
583
+ }
584
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
585
+ _latestStreamingMessage = null;
586
+ } else {
587
+ var finalMessage = streamingMessage.copyWith(
588
+ status: MessageStatus.complete,
589
+ isProcessing: false,
590
+ );
591
+
592
+ if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
593
+ try {
594
+ final registry = ref.read(toolRegistryProvider);
595
+ final adapter = createAdapterForServerType(server.type);
596
+
597
+ final dedupedCalls = <String, ToolCallData>{};
598
+ for (final tc in collectedToolCalls) {
599
+ dedupedCalls[tc.tool] = tc;
600
+ }
601
595
- if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
596
- try {
597
- final registry = ref.read(toolRegistryProvider);
598
- final adapter = createAdapterForServerType(server.type);
599
-
600
- final dedupedCalls = <String, ToolCallData>{};
601
- for (final tc in collectedToolCalls) {
602
- dedupedCalls[tc.tool] = tc;
603
- }
604
-
605
- final preParsedCalls = dedupedCalls.values
606
- .map(
607
- (tc) => ParsedToolCall(
608
- id: tc.tool,
609
- name: tc.tool,
610
- arguments: tc.arguments,
611
- ),
612
- )
613
- .toList();
614
-
615
- final loop = ToolExecutionLoop(
616
- adapter: adapter,
617
- registry: registry,
618
- onRequestApproval: (call) async {
619
- final completer = Completer<bool>();
620
- state = state.copyWith(
621
- pendingToolApproval: PendingToolApproval(
622
- toolCall: call,
623
- completer: completer,
624
- ),
625
- );
626
-
627
- final result = await completer.future;
628
-
629
- state = state.copyWith(clearPendingApproval: true);
630
-
631
- return result;
632
- },
633
- );
602
+ final preParsedCalls = dedupedCalls.values
603
+ .map(
604
+ (tc) => ParsedToolCall(
605
+ id: tc.tool,
606
+ name: tc.tool,
607
+ arguments: tc.arguments,
608
+ ),
609
+ )
610
+ .toList();
611
+
612
+ final loop = ToolExecutionLoop(
613
+ adapter: adapter,
614
+ registry: registry,
615
+ onRequestApproval: (call) async {
616
+ final completer = Completer<bool>();
617
+ state = state.copyWith(
618
+ pendingToolApproval: PendingToolApproval(
619
+ toolCall: call,
620
+ completer: completer,
621
+ ),
622
+ );
623
635
- final loopResult = await loop.run(
636
- initialUserMessage: content,
637
- assistantContent: streamingAssistantMessage.content,
638
- preParsedCalls: preParsedCalls,
639
- );
624
+ final result = await completer.future;
625
641
- if (loopResult.events.isNotEmpty) {
642
- finalMessage = finalMessage.copyWith(
643
- toolSessionId: loop.sessionId,
644
- toolEvents: loopResult.events,
645
- );
646
- }
647
- } catch (e) {
648
- Log.error('Tool execution loop failed: $e');
649
- }
650
- }
626
+ state = state.copyWith(clearPendingApproval: true);
627
652
- await _saveMessage(finalMessage);
653
- if (isCurrentContext) {
654
- _replaceMessageInState(
655
- finalMessage,
656
- clearStreaming: true,
628
+ return result;
629
+ },
630
+ );
631
+
632
+ final loopResult = await loop.run(
633
+ initialUserMessage: content,
634
+ assistantContent: streamingAssistantMessage.content,
635
+ preParsedCalls: preParsedCalls,
636
);
658
- state = state.copyWith(isStreaming: false);
659
- }
660
- ref.read(isStreamingProvider.notifier).setStreaming(false);
661
- _latestStreamingMessage = null;
662
- if (_currentConversationId != null) {
663
- final preview = finalMessage.content.length > 100
664
- ? '${finalMessage.content.substring(0, 100)}...'
665
- : finalMessage.content;
666
- await ref
667
- .read(conv.conversationsProvider.notifier)
668
- .updatePreview(
669
- _currentConversationId!,
670
- preview,
671
- DateTime.now(),
672
- );
637
674
- final userMessage = state.messages
675
- .where((m) => m.role == MessageRole.user)
676
- .firstOrNull;
677
- if (userMessage != null &&
678
- userMessage.content.length > 10) {
679
- _autoGenerateTitle(
680
- userMessage.content,
681
- finalMessage.content,
638
+ if (loopResult.events.isNotEmpty) {
639
+ finalMessage = finalMessage.copyWith(
640
+ toolSessionId: loop.sessionId,
641
+ toolEvents: loopResult.events,
642
);
643
}
644
+ } catch (e) {
645
+ Log.error('Tool execution loop failed: $e');
646
}
647
+ }
648
686
- _maybeRequestReviewAfterSuccessfulCompletion(
687
- finalMessage: finalMessage,
688
- server: server,
689
- selectedModel: selectedModel,
649
+ await _saveMessage(finalMessage);
650
+ if (isCurrentContext) {
651
+ _replaceMessageInState(
652
+ finalMessage,
653
+ clearStreaming: true,
654
);
655
+ state = state.copyWith(isStreaming: false);
656
+ }
657
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
658
+ _latestStreamingMessage = null;
659
+ if (_currentConversationId != null) {
660
+ final preview = finalMessage.content.length > 100
661
+ ? '${finalMessage.content.substring(0, 100)}...'
662
+ : finalMessage.content;
663
+ await ref
664
+ .read(conv.conversationsProvider.notifier)
665
+ .updatePreview(
666
+ _currentConversationId!,
667
+ preview,
668
+ DateTime.now(),
669
+ );
670
692
- _chunkCount = 0;
693
- _lastCheckpointTime = null;
694
- _lastSavedContentLength = 0;
695
- _lastSavedReasoningLength = 0;
671
+ final userMessage = state.messages
672
+ .where((m) => m.role == MessageRole.user)
673
+ .firstOrNull;
674
+ if (userMessage != null &&
675
+ userMessage.content.length > 10) {
676
+ _autoGenerateTitle(
677
+ userMessage.content,
678
+ finalMessage.content,
679
+ );
680
+ }
681
}
682
+
683
+ _maybeRequestReviewAfterSuccessfulCompletion(
684
+ finalMessage: finalMessage,
685
+ server: server,
686
+ selectedModel: selectedModel,
687
+ );
688
+
689
+ _chunkCount = 0;
690
+ _lastCheckpointTime = null;
691
+ _lastSavedContentLength = 0;
692
+ _lastSavedReasoningLength = 0;
693
}
694
},
695
onError: (error) async {
lib/features/chat/providers/model_selection_providers.dart
-1
@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:localmind/core/models/enums.dart';
5
import 'package:localmind/core/providers/service_providers.dart';
6
import 'package:localmind/features/models/data/models/model_info.dart';
7
-import 'package:localmind/features/servers/data/models/server.dart';
7
import 'package:localmind/features/servers/providers/server_providers.dart';
8
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
9
lib/features/chat/providers/smart_reply_providers.dart
-2
@@ -6,8 +6,6 @@ import 'package:localmind/core/providers/app_providers.dart';
6
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7
as conv;
8
import 'package:localmind/features/servers/providers/server_providers.dart';
9
-import '../data/chat_service.dart';
10
-import '../data/smart_reply_service.dart';
9
import 'chat_notifier.dart';
10
import 'chat_params_providers.dart';
11
import 'chat_service_providers.dart';
lib/features/chat/views/chat_screen.dart
-1
@@ -283,7 +283,6 @@ class _ChatBody extends ConsumerWidget {
283
284
@override
285
Widget build(BuildContext context, WidgetRef ref) {
286
- final l10n = AppLocalizations.of(context)!;
286
final theme = Theme.of(context);
287
final isDark = theme.brightness == Brightness.dark;
288
final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
lib/features/chat/views/components/message_list/message_list.dart
-1
@@ -121,7 +121,6 @@ class _MessageList extends StatelessWidget {
121
122
return ListView.builder(
123
controller: scrollController,
124
- cacheExtent: 1000,
124
padding: EdgeInsets.only(
125
top: 16,
126
bottom: 120 + (hasSmartReplies ? 64 : 0) + bottomInset,
lib/features/models/components/model_list.dart
-1
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:localmind/features/chat/providers/model_loading_providers.dart';
4
import 'package:localmind/core/models/enums.dart';
5
-import 'package:localmind/core/providers/app_providers.dart';
5
import 'package:localmind/core/providers/service_providers.dart';
6
import 'package:localmind/core/theme/colors.dart';
7
import 'package:localmind/features/chat/providers/model_selection_providers.dart';
lib/features/on_device/components/memory_stats_panel.dart
+1
-1
@@ -190,7 +190,7 @@ class _MemoryPanelContent extends StatelessWidget {
190
mainAxisAlignment: MainAxisAlignment.spaceBetween,
191
children: [
192
Text(
193
- l10n.ram_used('${(usagePercent * 100).toStringAsFixed(0)}'),
193
+ l10n.ram_used((usagePercent * 100).toStringAsFixed(0)),
194
style: theme.textTheme.labelSmall?.copyWith(
195
fontWeight: FontWeight.w600,
196
),
lib/features/on_device/components/on_device_picker_section.dart
-1
@@ -7,7 +7,6 @@ import 'package:localmind/core/device/device_memory_service.dart';
7
import 'package:localmind/core/models/enums.dart';
8
import 'package:localmind/core/providers/app_providers.dart';
9
import 'package:localmind/core/providers/device_info_providers.dart';
10
-import 'package:localmind/core/providers/service_providers.dart';
10
import 'package:localmind/core/theme/colors.dart';
11
import 'package:localmind/features/chat/providers/model_selection_providers.dart';
12
import 'package:localmind/features/models/data/models/model_info.dart';
lib/features/on_device/views/model_manager_screen.dart
-2
@@ -1,5 +1,3 @@
1
-import 'dart:io';
2
-
1
import 'package:file_picker/file_picker.dart';
2
import 'package:flutter/foundation.dart';
3
import 'package:flutter/material.dart';
lib/l10n/app_localizations_zh.dart
-3
@@ -2455,19 +2455,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
2455
@override
2456
String get server_type_on_device => '裝置端';
2457
2458
- @override
2458
String get server_type_on_device_sub => '無需伺服器';
2459
2460
@override
2461
String get server_type_lm_studio => 'LM Studio';
2462
2464
- @override
2463
String get server_type_lm_studio_sub => '本機 API';
2464
2465
@override
2466
String get server_type_ollama => 'Ollama';
2467
2470
- @override
2468
String get server_type_ollama_sub => 'CLI 引擎';
2469
2470
@override