@tduncan / Cortana / commits / 702e8c7

feat: add automated scroll management and tool execution approval flow to chat interface

abdulmominsakib committed May 27, 2026 at 11:05 UTC 702e8c7c1c1992eed2a4465a789ba53f839edd19
22 files changed +1418 -653
lib/app.dart
+6
@@ -11,6 +11,7 @@ import 'core/routes/app_routes.dart';
11 import 'core/theme/app_theme.dart';
12 import 'features/chat/views/chat_screen.dart';
13 import 'features/conversations/views/chat_history_screen.dart';
14 +import 'features/mcp/views/mcp_tools_screen.dart';
15 import 'features/on_device/views/model_manager_screen.dart';
16 import 'features/onboarding/screens/onboarding_language_screen.dart';
17 import 'features/onboarding/screens/onboarding_model_download_screen.dart';
@@ -137,6 +138,11 @@ final routerProvider = Provider<GoRouter>((ref) {
138 pageBuilder: (context, state) =>
139 const NoTransitionPage(child: ChatHistoryScreen()),
140 ),
141 + GoRoute(
142 + path: AppRoutes.mcpTools,
143 + pageBuilder: (context, state) =>
144 + const NoTransitionPage(child: McpToolsScreen()),
145 + ),
146 GoRoute(
147 path: AppRoutes.onDeviceModels,
148 pageBuilder: (context, state) =>
lib/bootstrap/bootstrap_host.dart
+6 -2
@@ -12,6 +12,7 @@ import '../features/on_device/data/on_device_gemma_service.dart';
12 import '../core/models/enums.dart';
13 import '../features/servers/data/models/server.dart';
14 import '../features/servers/providers/server_providers.dart';
15 +import '../core/logger/app_logger.dart';
16 import 'bootstrap_screen.dart';
17 import 'bootstrap_state.dart';
18
@@ -67,8 +68,11 @@ class _BootstrapHostState extends State<BootstrapHost> {
68 _updateStage(BootstrapStage.initializingServices, 'Initializing services...');
69 await OnDeviceGemmaService.initialize();
70
70 - // Migrate models from old custom download location
71 - await OnDeviceGemmaService.migrateOldModels();
71 + // Migrate models from old custom download location in background to prevent cold start ANR
72 + OnDeviceGemmaService.migrateOldModels().catchError((e) {
73 + Log.error('Error migrating old models: $e');
74 + return 0;
75 + });
76
77 _updateStage(BootstrapStage.configuringServer, 'Configuring server...');
78 final servers = await container.read(serversProvider.future);
lib/core/routes/app_routes.dart
+1
@@ -12,6 +12,7 @@ class AppRoutes {
12 static const String createPersona = '/personas/create';
13 static const String settings = '/settings';
14 static const String models = '/models';
15 + static const String mcpTools = '/mcp-tools';
16 static const String chatHistory = '/history';
17 static const String onDeviceModels = '/on-device-models';
18 static const String ttsModels = '/tts-models';
lib/core/services/message_save_service.dart
+31 -6
@@ -18,6 +18,9 @@ class MessageSaveService {
18 final List<Message> _queue = [];
19 Timer? _flushTimer;
20 bool _disposed = false;
21 + bool _isFlushing = false;
22 + bool _flushAgain = false;
23 + Completer<void>? _activeFlushCompleter;
24
25 MessageSaveService(this._db);
26
@@ -49,14 +52,36 @@ class MessageSaveService {
52 }
53
54 Future<void> _doFlush() async {
52 - if (_queue.isEmpty) return;
53 - final batch = List<Message>.from(_queue);
54 - _queue.clear();
55 + if (_isFlushing) {
56 + _flushAgain = true;
57 + await _activeFlushCompleter?.future;
58 + return;
59 + }
60
61 + _isFlushing = true;
62 + _activeFlushCompleter = Completer<void>();
63 try {
57 - await _db.store.runInTransactionAsync(TxMode.write, _writeBatch, batch);
58 - } catch (e) {
59 - Log.error('MessageSaveService flush error: $e');
64 + do {
65 + _flushAgain = false;
66 + if (_queue.isEmpty) break;
67 +
68 + final batch = List<Message>.from(_queue);
69 + _queue.clear();
70 +
71 + try {
72 + await _db.store.runInTransactionAsync(
73 + TxMode.write,
74 + _writeBatch,
75 + batch,
76 + );
77 + } catch (e) {
78 + Log.error('MessageSaveService flush error: $e');
79 + }
80 + } while (_flushAgain || _queue.isNotEmpty);
81 + } finally {
82 + _isFlushing = false;
83 + _activeFlushCompleter?.complete();
84 + _activeFlushCompleter = null;
85 }
86 }
87
lib/features/chat/data/chat_service.dart
+11 -4
@@ -1,6 +1,7 @@
1 import 'dart:async';
2 import 'dart:convert';
3 import 'dart:io';
4 +import 'dart:isolate';
5 import 'package:dio/dio.dart';
6 import '../../servers/data/models/server.dart';
7 import 'models/message.dart';
@@ -405,10 +406,16 @@ class LMStudioChatService implements ChatService {
406 formattedInputs.add({'type': 'text', 'content': m.content});
407 for (final path in m.attachmentPaths!) {
408 try {
408 - final file = File(path);
409 - if (await file.exists()) {
410 - final bytes = await file.readAsBytes();
411 - final base64Image = base64Encode(bytes);
409 + final base64Image = await Isolate.run(() async {
410 + final file = File(path);
411 + if (await file.exists()) {
412 + final bytes = await file.readAsBytes();
413 + return base64Encode(bytes);
414 + }
415 + return null;
416 + });
417 +
418 + if (base64Image != null) {
419 final ext = path.split('.').last.toLowerCase();
420 final mimeType = (ext == 'png')
421 ? 'image/png'
lib/features/chat/data/mcp_client.dart
+6 -2
@@ -185,7 +185,7 @@ class McpCapabilities {
185 final bool resources;
186 final bool prompts;
187
188 - McpCapabilities({
188 + const McpCapabilities({
189 this.tools = false,
190 this.resources = false,
191 this.prompts = false,
@@ -205,7 +205,11 @@ class McpTool {
205 final String? description;
206 final Map<String, dynamic> inputSchema;
207
208 - McpTool({required this.name, this.description, this.inputSchema = const {}});
208 + const McpTool({
209 + required this.name,
210 + this.description,
211 + this.inputSchema = const {},
212 + });
213
214 factory McpTool.fromJson(Map<String, dynamic> json) {
215 return McpTool(
lib/features/chat/data/mcp_server_manager.dart
+79 -3
@@ -1,10 +1,14 @@
1 import 'mcp_client.dart';
2
3 +const exampleMcpServerLabel = 'Example MCP';
4 +const exampleMcpServerUrl = 'local://example-mcp';
5 +
6 class McpServerManager {
7 final Map<String, McpClient> _clients = {};
8 final Map<String, McpCapabilities> _capabilities = {};
9 final Map<String, List<McpTool>> _tools = {};
10 final Map<String, String> _serverUrls = {};
11 + final Set<String> _localExampleServers = {};
12
13 Future<void> addServer(
14 String label,
@@ -32,9 +36,52 @@ class McpServerManager {
36 _capabilities.remove(label);
37 _tools.remove(label);
38 _serverUrls.remove(label);
39 + _localExampleServers.remove(label);
40 + }
41 +
42 + void addExampleServer() {
43 + removeServer(exampleMcpServerLabel);
44 +
45 + _capabilities[exampleMcpServerLabel] = const McpCapabilities(tools: true);
46 + _tools[exampleMcpServerLabel] = const [
47 + McpTool(
48 + name: 'example.echo',
49 + description: 'Echo a message back from the example MCP server.',
50 + inputSchema: {
51 + 'type': 'object',
52 + 'properties': {
53 + 'message': {
54 + 'type': 'string',
55 + 'description': 'The message to echo.',
56 + },
57 + },
58 + 'required': ['message'],
59 + },
60 + ),
61 + McpTool(
62 + name: 'example.word_count',
63 + description: 'Count the words in a text string.',
64 + inputSchema: {
65 + 'type': 'object',
66 + 'properties': {
67 + 'text': {
68 + 'type': 'string',
69 + 'description': 'The text to count words in.',
70 + },
71 + },
72 + 'required': ['text'],
73 + },
74 + ),
75 + ];
76 + _serverUrls[exampleMcpServerLabel] = exampleMcpServerUrl;
77 + _localExampleServers.add(exampleMcpServerLabel);
78 }
79
37 - bool hasServer(String label) => _clients.containsKey(label);
80 + bool hasServer(String label) =>
81 + _clients.containsKey(label) || _localExampleServers.contains(label);
82 +
83 + bool hasExampleServer() =>
84 + _localExampleServers.contains(exampleMcpServerLabel);
85
86 List<McpTool> getTools(String label) => _tools[label] ?? [];
87
@@ -49,6 +96,10 @@ class McpServerManager {
96 String toolName,
97 Map<String, dynamic> args,
98 ) async {
99 + if (_localExampleServers.contains(serverLabel)) {
100 + return _callExampleTool(toolName, args);
101 + }
102 +
103 final client = _clients[serverLabel];
104 if (client == null) {
105 throw McpException('MCP server not connected: $serverLabel');
@@ -82,9 +133,34 @@ class McpServerManager {
133 _capabilities.clear();
134 _tools.clear();
135 _serverUrls.clear();
136 + _localExampleServers.clear();
137 + }
138 +
139 + String _callExampleTool(String toolName, Map<String, dynamic> args) {
140 + switch (toolName) {
141 + case 'example.echo':
142 + final message = args['message'];
143 + if (message is! String) {
144 + throw McpException('example.echo requires a string message');
145 + }
146 + return message;
147 + case 'example.word_count':
148 + final text = args['text'];
149 + if (text is! String) {
150 + throw McpException('example.word_count requires a string text value');
151 + }
152 + final words = text
153 + .trim()
154 + .split(RegExp(r'\s+'))
155 + .where((word) => word.isNotEmpty)
156 + .length;
157 + return words.toString();
158 + default:
159 + throw McpException('Example MCP tool not found: $toolName');
160 + }
161 }
162
87 - int get serverCount => _clients.length;
163 + int get serverCount => _tools.length;
164
89 - List<String> get serverLabels => _clients.keys.toList();
165 + List<String> get serverLabels => _tools.keys.toList();
166 }
lib/features/chat/providers/chat_providers.dart
+272 -183
@@ -18,6 +18,7 @@ import '../../../core/models/enums.dart';
18 import '../../../core/providers/service_providers.dart';
19 import '../../../core/providers/storage_providers.dart';
20 import '../../on_device/providers/on_device_providers.dart';
21 +import '../../on_device/data/on_device_chat_service.dart';
22 import '../../../core/storage/entities.dart';
23 import '../../../objectbox.g.dart';
24 import '../../conversations/data/models/conversation.dart';
@@ -84,7 +85,9 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
85 final Set<String> loadedModels;
86 if (activeServer.type == ServerType.onDevice) {
87 final engine = ref.watch(onDeviceEngineProvider);
87 - loadedModels = engine.loadedModelId != null ? {engine.loadedModelId!} : {};
88 + loadedModels = engine.loadedModelId != null
89 + ? {engine.loadedModelId!}
90 + : {};
91 } else {
92 final apiService = ref.read(serverApiServiceProvider);
93 loadedModels = await apiService.fetchRunningModels(activeServer);
@@ -242,15 +245,26 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
245 if (server == null) {
246 return null;
247 }
248 + final ChatService service;
249 if (server.type == ServerType.onDevice) {
250 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
247 - return ChatService.forServer(
251 + service = ChatService.forServer(
252 server.type,
253 ref.read(dioProvider),
254 onDeviceGemma: gemmaService,
255 );
256 + } else {
257 + service = ChatService.forServer(server.type, ref.read(dioProvider));
258 }
253 - return ChatService.forServer(server.type, ref.read(dioProvider));
259 +
260 + ref.onDispose(() {
261 + final s = service;
262 + if (s is OnDeviceChatService) {
263 + s.dispose();
264 + }
265 + });
266 +
267 + return service;
268 });
269
270 final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
@@ -260,13 +274,15 @@ final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
274 });
275
276 final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
263 - final chatState = ref.watch(chatProvider);
264 - final isStreaming = ref.watch(isStreamingProvider);
277 + final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
278 final settings = ref.watch(settingsProvider);
279
267 - if (chatState.messages.isEmpty || isStreaming) return [];
280 + if (isStreaming) return [];
281
269 - final lastMessage = chatState.messages.last;
282 + final messages = ref.watch(chatProvider.select((s) => s.messages));
283 + if (messages.isEmpty) return [];
284 +
285 + final lastMessage = messages.last;
286 if (lastMessage.role != MessageRole.assistant ||
287 lastMessage.status != MessageStatus.complete) {
288 return [];
@@ -295,9 +311,10 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
311 chatService: chatService,
312 server: server,
313 modelId: selectedModel.id,
298 - messages: chatState.messages,
314 + messages: messages,
315 params: chatParams,
316 );
317 + if (!ref.mounted) return [];
318 }
319 }
320
@@ -309,21 +326,31 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
326 // Save the replies to the conversation database
327 if (activeConv != null && suggestions.isNotEmpty) {
328 Future.microtask(() {
312 - ref
313 - .read(conv.conversationsProvider.notifier)
314 - .updateSmartReplies(activeConv.id, suggestions, lastMessage.id);
329 + if (ref.mounted) {
330 + ref
331 + .read(conv.conversationsProvider.notifier)
332 + .updateSmartReplies(activeConv.id, suggestions, lastMessage.id);
333 + }
334 });
335 }
336
337 return suggestions;
338 });
339
340 +class PendingToolApproval {
341 + final ParsedToolCall toolCall;
342 + final Completer<bool> completer;
343 +
344 + PendingToolApproval({required this.toolCall, required this.completer});
345 +}
346 +
347 class ChatState {
348 final List<Message> messages;
349 final bool isStreaming;
350 final bool isLoading;
351 final String? errorMessage;
352 final Message? streamingMessage;
353 + final PendingToolApproval? pendingToolApproval;
354
355 const ChatState({
356 this.messages = const [],
@@ -331,6 +358,7 @@ class ChatState {
358 this.isLoading = false,
359 this.errorMessage,
360 this.streamingMessage,
361 + this.pendingToolApproval,
362 });
363
364 ChatState copyWith({
@@ -339,8 +367,10 @@ class ChatState {
367 bool? isLoading,
368 String? errorMessage,
369 Message? streamingMessage,
370 + PendingToolApproval? pendingToolApproval,
371 bool clearError = false,
372 bool clearStreaming = false,
373 + bool clearPendingApproval = false,
374 }) {
375 return ChatState(
376 messages: messages ?? this.messages,
@@ -350,6 +380,9 @@ class ChatState {
380 streamingMessage: clearStreaming
381 ? null
382 : (streamingMessage ?? this.streamingMessage),
383 + pendingToolApproval: clearPendingApproval
384 + ? null
385 + : (pendingToolApproval ?? this.pendingToolApproval),
386 );
387 }
388 }
@@ -363,6 +396,8 @@ class ChatNotifier extends Notifier<ChatState> {
396 static const _checkpointTimeThreshold = Duration(seconds: 2);
397
398 StreamSubscription<ChatResponse>? _streamSubscription;
399 + Timer? _uiUpdateTimer;
400 + Message? _latestStreamingMessage;
401 String? _currentConversationId;
402 int _chunkCount = 0;
403 DateTime? _lastCheckpointTime;
@@ -372,13 +407,35 @@ class ChatNotifier extends Notifier<ChatState> {
407 /// Batches checkpoint saves off the UI thread during streaming.
408 MessageSaveService? _saveService;
409
410 + void approveTool(bool approved) {
411 + final pending = state.pendingToolApproval;
412 + if (pending != null && !pending.completer.isCompleted) {
413 + pending.completer.complete(approved);
414 + }
415 + }
416 +
417 + void _clearPendingApproval() {
418 + final pending = state.pendingToolApproval;
419 + if (pending != null) {
420 + if (!pending.completer.isCompleted) {
421 + pending.completer.complete(false);
422 + }
423 + state = state.copyWith(clearPendingApproval: true);
424 + }
425 + }
426 +
427 @override
428 ChatState build() {
429 final db = ref.read(databaseProvider);
430 _saveService = MessageSaveService(db);
431 ref.onDispose(() {
432 + _uiUpdateTimer?.cancel();
433 _streamSubscription?.cancel();
434 _saveService?.dispose();
435 + final pending = state.pendingToolApproval;
436 + if (pending != null && !pending.completer.isCompleted) {
437 + pending.completer.complete(false);
438 + }
439 });
440 return const ChatState();
441 }
@@ -396,50 +453,11 @@ class ChatNotifier extends Notifier<ChatState> {
453 try {
454 final db = ref.read(databaseProvider);
455
399 - // Attempt to find the conversation entity to use its native relation
400 - final convQuery = db.conversationBox
401 - .query(ConversationEntity_.id.equals(conversation.id))
402 - .build();
403 - final convEntity = convQuery.findFirst();
404 - convQuery.close();
405 -
406 - List<Message> messages = [];
407 -
408 - if (convEntity != null) {
409 - // 1. Try native ToMany relation first (managed by ObjectBox internal IDs)
410 - final relatedEntities = convEntity.messages;
411 - if (relatedEntities.isNotEmpty) {
412 - messages = relatedEntities.map((e) => e.toDomain()).toList();
413 - } else {
414 - // 2. Fallback/Repair: Search by the indexed conversationUid string
415 - final query = db.messageBox
416 - .query(MessageEntity_.conversationUid.equals(conversation.id))
417 - .build();
418 - final manualEntities = query.find();
419 - query.close();
420 -
421 - if (manualEntities.isNotEmpty) {
422 - // Automatic Repair: Link orphaned messages to the native relation
423 - // for future consistency and performance.
424 - for (final e in manualEntities) {
425 - e.conversation.target = convEntity;
426 - db.messageBox.put(e);
427 - }
428 - messages = manualEntities.map((e) => e.toDomain()).toList();
429 - }
430 - }
431 - } else {
432 - // Fallback for cases where the conversation entity itself might be hard to find by ID
433 - // but messages exist tied to that UID string.
434 - final query = db.messageBox
435 - .query(MessageEntity_.conversationUid.equals(conversation.id))
436 - .build();
437 - final entities = query.find();
438 - query.close();
439 - messages = entities.map((e) => e.toDomain()).toList();
440 - }
441 -
442 - messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
456 + final messages = await db.store.runInTransactionAsync(
457 + TxMode.write,
458 + _loadMessagesInBackground,
459 + conversation.id,
460 + );
461
462 state = ChatState(messages: messages, isLoading: false);
463 ref
@@ -459,6 +477,53 @@ class ChatNotifier extends Notifier<ChatState> {
477 }
478 }
479
480 + static List<Message> _loadMessagesInBackground(
481 + Store store,
482 + String conversationId,
483 + ) {
484 + final convBox = store.box<ConversationEntity>();
485 + final messageBox = store.box<MessageEntity>();
486 +
487 + final convQuery = convBox
488 + .query(ConversationEntity_.id.equals(conversationId))
489 + .build();
490 + final convEntity = convQuery.findFirst();
491 + convQuery.close();
492 +
493 + List<Message> messages = [];
494 +
495 + if (convEntity != null) {
496 + final relatedEntities = convEntity.messages;
497 + if (relatedEntities.isNotEmpty) {
498 + messages = relatedEntities.map((e) => e.toDomain()).toList();
499 + } else {
500 + final query = messageBox
501 + .query(MessageEntity_.conversationUid.equals(conversationId))
502 + .build();
503 + final manualEntities = query.find();
504 + query.close();
505 +
506 + if (manualEntities.isNotEmpty) {
507 + for (final e in manualEntities) {
508 + e.conversation.target = convEntity;
509 + messageBox.put(e);
510 + }
511 + messages = manualEntities.map((e) => e.toDomain()).toList();
512 + }
513 + }
514 + } else {
515 + final query = messageBox
516 + .query(MessageEntity_.conversationUid.equals(conversationId))
517 + .build();
518 + final entities = query.find();
519 + query.close();
520 + messages = entities.map((e) => e.toDomain()).toList();
521 + }
522 +
523 + messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
524 + return messages;
525 + }
526 +
527 Future<void> startNewConversation() async {
528 await cancelStream();
529 _currentConversationId = null;
@@ -521,6 +586,38 @@ class ChatNotifier extends Notifier<ChatState> {
586 _lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
587 }
588
589 + Future<void> checkpointStreamingMessage({bool flush = false}) async {
590 + final latest = _latestStreamingMessage ?? state.streamingMessage;
591 + if (latest == null) return;
592 +
593 + _saveService?.enqueue(latest);
594 + _updateSavedMetrics(latest);
595 + _resetCheckpointMetrics();
596 +
597 + if (flush) {
598 + await _saveService?.flush();
599 + }
600 + }
601 +
602 + void _replaceMessageInState(Message message, {bool clearStreaming = false}) {
603 + final messageIndex = state.messages.indexWhere((m) => m.id == message.id);
604 + if (messageIndex == -1) {
605 + state = state.copyWith(
606 + streamingMessage: clearStreaming ? null : message,
607 + clearStreaming: clearStreaming,
608 + );
609 + return;
610 + }
611 +
612 + final updatedMessages = List<Message>.from(state.messages);
613 + updatedMessages[messageIndex] = message;
614 + state = state.copyWith(
615 + messages: updatedMessages,
616 + streamingMessage: clearStreaming ? null : message,
617 + clearStreaming: clearStreaming,
618 + );
619 + }
620 +
621 Future<void> sendMessage(String content, {List<File>? attachments}) async {
622 final server = ref.read(activeServerProvider);
623 final selectedModel = ref.read(selectedModelProvider);
@@ -609,6 +706,7 @@ class ChatNotifier extends Notifier<ChatState> {
706 streamingMessage: assistantMessage,
707 clearError: true,
708 );
709 + ref.read(isStreamingProvider.notifier).setStreaming(true);
710
711 await _saveMessage(userMessage);
712 await _saveMessage(assistantMessage);
@@ -623,18 +721,39 @@ class ChatNotifier extends Notifier<ChatState> {
721
722 try {
723 _streamSubscription?.cancel();
724 + _uiUpdateTimer?.cancel();
725
726 String reasoningContent = '';
727 var streamingAssistantMessage = assistantMessage;
728 + _latestStreamingMessage = streamingAssistantMessage;
729
730 if (chatService != null) {
731 final mcpConfig = ref.read(chatMcpConfigProvider);
632 - final integrations = mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
732 + final integrations =
733 + mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
734 ? mcpConfig.integrations
735 : null;
736
737 final collectedToolCalls = <ToolCallData>[];
738
739 + bool stateNeedsUpdate = false;
740 +
741 + void updateUiState() {
742 + if (!stateNeedsUpdate) return;
743 + stateNeedsUpdate = false;
744 +
745 + final streamConvId = assistantMessage.conversationId;
746 + final isCurrentContext = _currentConversationId == streamConvId;
747 +
748 + if (isCurrentContext) {
749 + state = state.copyWith(streamingMessage: streamingAssistantMessage);
750 + }
751 + }
752 +
753 + _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
754 + updateUiState();
755 + });
756 +
757 _streamSubscription = chatService
758 .sendMessage(
759 server: server,
@@ -657,6 +776,7 @@ class ChatNotifier extends Notifier<ChatState> {
776 (response.content ?? ''),
777 isProcessing: false,
778 );
779 + _latestStreamingMessage = streamingAssistantMessage;
780
781 _chunkCount++;
782
@@ -667,26 +787,7 @@ class ChatNotifier extends Notifier<ChatState> {
787 _resetCheckpointMetrics();
788 }
789
670 - if (isCurrentContext) {
671 - final messageIndex = state.messages.indexWhere(
672 - (m) => m.id == assistantMessageId,
673 - );
674 - if (messageIndex != -1) {
675 - final updatedMessages = List<Message>.from(
676 - state.messages,
677 - );
678 - updatedMessages[messageIndex] =
679 - streamingAssistantMessage;
680 - state = state.copyWith(
681 - streamingMessage: streamingAssistantMessage,
682 - messages: updatedMessages,
683 - );
684 - } else {
685 - state = state.copyWith(
686 - streamingMessage: streamingAssistantMessage,
687 - );
688 - }
689 - }
790 + stateNeedsUpdate = true;
791 break;
792 case ChatResponseType.reasoning:
793 reasoningContent += response.reasoningContent ?? '';
@@ -695,6 +796,7 @@ class ChatNotifier extends Notifier<ChatState> {
796 reasoningContent: reasoningContent,
797 isProcessing: false,
798 );
799 + _latestStreamingMessage = streamingAssistantMessage;
800
801 _chunkCount++;
802
@@ -705,45 +807,19 @@ class ChatNotifier extends Notifier<ChatState> {
807 _resetCheckpointMetrics();
808 }
809
708 - if (isCurrentContext) {
709 - state = state.copyWith(
710 - streamingMessage: streamingAssistantMessage,
711 - );
712 - final msgIndex = state.messages.indexWhere(
713 - (m) => m.id == assistantMessageId,
714 - );
715 - if (msgIndex != -1) {
716 - final updatedMessages = List<Message>.from(
717 - state.messages,
718 - );
719 - updatedMessages[msgIndex] = streamingAssistantMessage;
720 - state = state.copyWith(messages: updatedMessages);
721 - }
722 - }
810 + stateNeedsUpdate = true;
811 break;
812 case ChatResponseType.processing:
813 // Model is working but hasn't output content yet
814 streamingAssistantMessage = streamingAssistantMessage
815 .copyWith(isProcessing: true);
728 -
729 - if (isCurrentContext) {
730 - state = state.copyWith(
731 - streamingMessage: streamingAssistantMessage,
732 - );
733 - final procIndex = state.messages.indexWhere(
734 - (m) => m.id == assistantMessageId,
735 - );
736 - if (procIndex != -1) {
737 - final updatedMessages = List<Message>.from(
738 - state.messages,
739 - );
740 - updatedMessages[procIndex] = streamingAssistantMessage;
741 - state = state.copyWith(messages: updatedMessages);
742 - }
743 - }
816 + _latestStreamingMessage = streamingAssistantMessage;
817 + stateNeedsUpdate = true;
818 break;
819 case ChatResponseType.timeoutError:
820 case ChatResponseType.error:
821 + _uiUpdateTimer?.cancel();
822 + _uiUpdateTimer = null;
823 // Error reached without receiving content or mid-stream
824 streamingAssistantMessage = streamingAssistantMessage.copyWith(
825 status: MessageStatus.error,
@@ -754,28 +830,22 @@ class ChatNotifier extends Notifier<ChatState> {
830 : 'An unknown error occurred.'),
831 isProcessing: false,
832 );
833 + _latestStreamingMessage = streamingAssistantMessage;
834
835 if (isCurrentContext) {
836 + _replaceMessageInState(
837 + streamingAssistantMessage,
838 + clearStreaming: true,
839 + );
840 state = state.copyWith(
760 - streamingMessage: streamingAssistantMessage,
841 isStreaming: false,
842 + errorMessage: streamingAssistantMessage.errorMessage,
843 );
763 - final errIndex = state.messages.indexWhere(
764 - (m) => m.id == assistantMessageId,
765 - );
766 - if (errIndex != -1) {
767 - final updatedMessages = List<Message>.from(
768 - state.messages,
769 - );
770 - updatedMessages[errIndex] = streamingAssistantMessage;
771 - state = state.copyWith(
772 - messages: updatedMessages,
773 - errorMessage: streamingAssistantMessage.errorMessage,
774 - clearStreaming: true,
775 - );
776 - }
844 }
845 + ref.read(isStreamingProvider.notifier).setStreaming(false);
846 + _latestStreamingMessage = null;
847 // Save error message to storage
848 + await _saveService?.flush();
849 await _saveMessage(streamingAssistantMessage);
850 break;
851 case ChatResponseType.toolCall:
@@ -789,6 +859,10 @@ class ChatNotifier extends Notifier<ChatState> {
859 }
860 },
861 onDone: () async {
862 + _uiUpdateTimer?.cancel();
863 + _uiUpdateTimer = null;
864 + updateUiState();
865 +
866 // Flush batched checkpoint saves before finalising UI state.
867 await _saveService?.flush();
868 ref.read(chatBackgroundServiceProvider).stop();
@@ -812,22 +886,17 @@ class ChatNotifier extends Notifier<ChatState> {
886 );
887 await _saveMessage(errorMessage);
888 if (isCurrentContext) {
815 - final messageIndex = state.messages.indexWhere(
816 - (m) => m.id == assistantMessageId,
889 + _replaceMessageInState(
890 + errorMessage,
891 + clearStreaming: true,
892 + );
893 + state = state.copyWith(
894 + isStreaming: false,
895 + errorMessage: errorMessage.errorMessage,
896 );
818 - if (messageIndex != -1) {
819 - final updatedMessages = List<Message>.from(
820 - state.messages,
821 - );
822 - updatedMessages[messageIndex] = errorMessage;
823 - state = state.copyWith(
824 - messages: updatedMessages,
825 - isStreaming: false,
826 - errorMessage: errorMessage.errorMessage,
827 - clearStreaming: true,
828 - );
829 - }
897 }
898 + ref.read(isStreamingProvider.notifier).setStreaming(false);
899 + _latestStreamingMessage = null;
900 } else {
901 // Normal completion with content
902 var finalMessage = streamingMessage.copyWith(
@@ -842,11 +911,13 @@ class ChatNotifier extends Notifier<ChatState> {
911 final adapter = createAdapterForServerType(server.type);
912
913 final preParsedCalls = collectedToolCalls
845 - .map((tc) => ParsedToolCall(
846 - id: tc.tool,
847 - name: tc.tool,
848 - arguments: tc.arguments,
849 - ))
914 + .map(
915 + (tc) => ParsedToolCall(
916 + id: tc.tool,
917 + name: tc.tool,
918 + arguments: tc.arguments,
919 + ),
920 + )
921 .toList();
922
923 final loop = ToolExecutionLoop(
@@ -855,8 +926,21 @@ class ChatNotifier extends Notifier<ChatState> {
926 onRequestApproval: mcpConfig.autoExecuteTools
927 ? null
928 : (call) async {
858 - // TODO: wire real approval UI dialog
859 - return false;
929 + final completer = Completer<bool>();
930 + state = state.copyWith(
931 + pendingToolApproval: PendingToolApproval(
932 + toolCall: call,
933 + completer: completer,
934 + ),
935 + );
936 +
937 + final result = await completer.future;
938 +
939 + state = state.copyWith(
940 + clearPendingApproval: true,
941 + );
942 +
943 + return result;
944 },
945 );
946
@@ -880,21 +964,14 @@ class ChatNotifier extends Notifier<ChatState> {
964
965 await _saveMessage(finalMessage);
966 if (isCurrentContext) {
883 - final messageIndex = state.messages.indexWhere(
884 - (m) => m.id == assistantMessageId,
967 + _replaceMessageInState(
968 + finalMessage,
969 + clearStreaming: true,
970 );
886 - if (messageIndex != -1) {
887 - final updatedMessages = List<Message>.from(
888 - state.messages,
889 - );
890 - updatedMessages[messageIndex] = finalMessage;
891 - state = state.copyWith(
892 - messages: updatedMessages,
893 - isStreaming: false,
894 - clearStreaming: true,
895 - );
896 - }
971 + state = state.copyWith(isStreaming: false);
972 }
973 + ref.read(isStreamingProvider.notifier).setStreaming(false);
974 + _latestStreamingMessage = null;
975 if (_currentConversationId != null) {
976 final preview = finalMessage.content.length > 100
977 ? '${finalMessage.content.substring(0, 100)}...'
@@ -927,16 +1004,20 @@ class ChatNotifier extends Notifier<ChatState> {
1004 }
1005 },
1006 onError: (error) async {
1007 + _uiUpdateTimer?.cancel();
1008 + _uiUpdateTimer = null;
1009 _chunkCount = 0;
1010 _lastCheckpointTime = null;
1011 _lastSavedContentLength = 0;
1012 _lastSavedReasoningLength = 0;
1013 // Flush any batched saves before processing the error state.
1014 await _saveService?.flush();
936 - final errorMessage = state.streamingMessage?.copyWith(
937 - status: MessageStatus.error,
938 - errorMessage: error.toString(),
939 - );
1015 + final errorMessage =
1016 + (_latestStreamingMessage ?? state.streamingMessage)
1017 + ?.copyWith(
1018 + status: MessageStatus.error,
1019 + errorMessage: error.toString(),
1020 + );
1021 if (errorMessage != null) {
1022 await _saveMessage(errorMessage);
1023
@@ -944,27 +1025,21 @@ class ChatNotifier extends Notifier<ChatState> {
1025
1026 final streamConvId = assistantMessage.conversationId;
1027 if (_currentConversationId == streamConvId) {
947 - final messageIndex = state.messages.indexWhere(
948 - (m) => m.id == assistantMessageId,
1028 + _replaceMessageInState(errorMessage, clearStreaming: true);
1029 + state = state.copyWith(
1030 + isStreaming: false,
1031 + errorMessage: error.toString(),
1032 );
950 - if (messageIndex != -1) {
951 - final updatedMessages = List<Message>.from(
952 - state.messages,
953 - );
954 - updatedMessages[messageIndex] = errorMessage;
955 - state = state.copyWith(
956 - messages: updatedMessages,
957 - isStreaming: false,
958 - errorMessage: error.toString(),
959 - clearStreaming: true,
960 - );
961 - }
1033 }
1034 }
1035 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1036 + _latestStreamingMessage = null;
1037 },
1038 );
1039 }
1040 } catch (e) {
1041 + _uiUpdateTimer?.cancel();
1042 + _uiUpdateTimer = null;
1043 final errorMsg = assistantMessage.copyWith(
1044 status: MessageStatus.error,
1045 errorMessage: e.toString(),
@@ -977,6 +1052,8 @@ class ChatNotifier extends Notifier<ChatState> {
1052 errorMessage: e.toString(),
1053 clearStreaming: true,
1054 );
1055 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1056 + _latestStreamingMessage = null;
1057 ref.read(chatBackgroundServiceProvider).stop();
1058 }
1059 }
@@ -1088,6 +1165,9 @@ class ChatNotifier extends Notifier<ChatState> {
1165 }
1166
1167 Future<void> cancelStream() async {
1168 + _uiUpdateTimer?.cancel();
1169 + _uiUpdateTimer = null;
1170 + _clearPendingApproval();
1171 _streamSubscription?.cancel();
1172 _streamSubscription = null;
1173 ref.read(chatServiceProvider)?.cancelStream();
@@ -1101,16 +1181,19 @@ class ChatNotifier extends Notifier<ChatState> {
1181 // Flush any in-flight checkpoint saves before writing the final state.
1182 await _saveService?.flush();
1183
1104 - final streamingMessage = state.streamingMessage;
1184 + final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
1185 if (streamingMessage != null) {
1186 final finalMessage = streamingMessage.copyWith(
1187 status: MessageStatus.complete,
1188 isProcessing: false,
1189 );
1190 await _saveMessage(finalMessage);
1191 + _replaceMessageInState(finalMessage, clearStreaming: true);
1192 }
1193
1194 state = state.copyWith(isStreaming: false, clearStreaming: true);
1195 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1196 + _latestStreamingMessage = null;
1197 }
1198
1199 Future<void> retryLastMessage() async {
@@ -1232,9 +1315,18 @@ class ChatNotifier extends Notifier<ChatState> {
1315
1316 Future<void> _saveMessage(Message message) async {
1317 final db = ref.read(databaseProvider);
1235 - final query = db.messageBox
1236 - .query(MessageEntity_.id.equals(message.id))
1237 - .build();
1318 + await db.store.runInTransactionAsync(
1319 + TxMode.write,
1320 + _saveMessageInBackground,
1321 + message,
1322 + );
1323 + }
1324 +
1325 + static void _saveMessageInBackground(Store store, Message message) {
1326 + final box = store.box<MessageEntity>();
1327 + final convBox = store.box<ConversationEntity>();
1328 +
1329 + final query = box.query(MessageEntity_.id.equals(message.id)).build();
1330 final existing = query.findFirst();
1331 query.close();
1332
@@ -1243,8 +1335,7 @@ class ChatNotifier extends Notifier<ChatState> {
1335 entity.internalId = existing.internalId;
1336 }
1337
1246 - // Set native ObjectBox relation
1247 - final convQuery = db.conversationBox
1338 + final convQuery = convBox
1339 .query(ConversationEntity_.id.equals(message.conversationId))
1340 .build();
1341 final convEntity = convQuery.findFirst();
@@ -1252,14 +1343,12 @@ class ChatNotifier extends Notifier<ChatState> {
1343
1344 if (convEntity != null) {
1345 entity.conversation.target = convEntity;
1255 - // Also ensure the string UID fallback is set correctly
1346 entity.conversationUid = convEntity.id;
1347 } else {
1258 - // Fallback: search by UID if the entity was just created in this frame (unlikely but safe)
1348 entity.conversationUid = message.conversationId;
1349 }
1350
1262 - db.messageBox.put(entity);
1351 + box.put(entity);
1352 }
1353
1354 Future<void> clearConversation() async {
lib/features/chat/providers/tooling_providers.dart
+1 -2
@@ -17,8 +17,7 @@ final toolRegistryProvider = Provider<ToolRegistry>((ref) {
17 return ToolRegistry(
18 providers: [
19 ref.watch(builtInToolProviderProvider),
20 - if (mcpServerManager.serverCount > 0)
21 - McpToolProvider(serverManager: mcpServerManager),
20 + McpToolProvider(serverManager: mcpServerManager),
21 ],
22 );
23 });
lib/features/chat/views/chat_screen.dart
+208 -13
@@ -1,3 +1,4 @@
1 +import 'dart:convert';
2 import 'dart:ui';
3
4 import 'package:flutter/material.dart';
@@ -35,9 +36,16 @@ class ChatScreen extends ConsumerStatefulWidget {
36 ConsumerState<ChatScreen> createState() => _ChatScreenState();
37 }
38
38 -class _ChatScreenState extends ConsumerState<ChatScreen> {
39 +class _ChatScreenState extends ConsumerState<ChatScreen>
40 + with WidgetsBindingObserver {
41 final ScrollController _scrollController = ScrollController();
42 final FocusNode _inputFocusNode = FocusNode();
43 + bool _isApprovalDialogOpen = false;
44 + bool _autoScrollEnabled = true;
45 + bool _scheduledAutoScroll = false;
46 + int _lastMessageCount = 0;
47 + int _lastStreamingLength = 0;
48 + bool _lastIsStreaming = false;
49
50 List<String> _quickPrompts(AppLocalizations l10n) => [
51 l10n.quick_write,
@@ -49,15 +57,63 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
57 @override
58 void initState() {
59 super.initState();
60 + WidgetsBinding.instance.addObserver(this);
61 _inputFocusNode.addListener(_onFocusChanged);
62 + _scrollController.addListener(_onScrollChanged);
63 + }
64 +
65 + @override
66 + void didChangeAppLifecycleState(AppLifecycleState state) {
67 + if (state == AppLifecycleState.inactive ||
68 + state == AppLifecycleState.paused ||
69 + state == AppLifecycleState.hidden ||
70 + state == AppLifecycleState.detached) {
71 + ref.read(chatProvider.notifier).checkpointStreamingMessage(flush: true);
72 + }
73 }
74
75 void _onFocusChanged() {
76 setState(() {});
77 }
78
79 + void _onScrollChanged() {
80 + if (!_scrollController.hasClients) return;
81 + _autoScrollEnabled = _isNearBottom();
82 + }
83 +
84 + bool _isNearBottom() {
85 + if (!_scrollController.hasClients) return true;
86 + final position = _scrollController.position;
87 + return (position.maxScrollExtent - position.pixels) <= 120;
88 + }
89 +
90 + void _scheduleAutoScroll({required bool streaming}) {
91 + if (!_autoScrollEnabled || _scheduledAutoScroll) {
92 + return;
93 + }
94 +
95 + _scheduledAutoScroll = true;
96 + WidgetsBinding.instance.addPostFrameCallback((_) {
97 + _scheduledAutoScroll = false;
98 + if (!_scrollController.hasClients || !_autoScrollEnabled) {
99 + return;
100 + }
101 +
102 + final target = _scrollController.position.maxScrollExtent;
103 + _scrollController.animateTo(
104 + target,
105 + duration: streaming
106 + ? const Duration(milliseconds: 120)
107 + : const Duration(milliseconds: 200),
108 + curve: Curves.easeOutCubic,
109 + );
110 + });
111 + }
112 +
113 @override
114 void dispose() {
115 + WidgetsBinding.instance.removeObserver(this);
116 + _scrollController.removeListener(_onScrollChanged);
117 _scrollController.dispose();
118 _inputFocusNode.removeListener(_onFocusChanged);
119 _inputFocusNode.dispose();
@@ -69,7 +125,22 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
125 final l10n = AppLocalizations.of(context)!;
126 ref.watch(autoSelectFirstLoadedModelProvider);
127
72 - final chatState = ref.watch(chatProvider);
128 + ref.listen<PendingToolApproval?>(
129 + chatProvider.select((s) => s.pendingToolApproval),
130 + (previous, next) {
131 + if (next != null && !_isApprovalDialogOpen) {
132 + _showToolApprovalDialog(context, next);
133 + }
134 + },
135 + );
136 +
137 + final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
138 + final messages = ref.watch(chatProvider.select((s) => s.messages));
139 + final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
140 + final streamingLength = ref.watch(
141 + chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
142 + );
143 + final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
144 final selectedModel = ref.watch(selectedModelProvider);
145 final connectionStatus = ref.watch(connectionStatusProvider);
146 final activeServer = ref.watch(activeServerProvider);
@@ -81,6 +152,20 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
152 final theme = Theme.of(context);
153 final isDark = theme.brightness == Brightness.dark;
154
155 + final hasNewMessage = messages.length != _lastMessageCount;
156 + final streamingProgressed =
157 + isStreaming && streamingLength != _lastStreamingLength;
158 + final streamStarted = isStreaming && !_lastIsStreaming;
159 + final streamEnded = !isStreaming && _lastIsStreaming;
160 +
161 + if (hasNewMessage || streamingProgressed || streamStarted || streamEnded) {
162 + _scheduleAutoScroll(streaming: isStreaming);
163 + }
164 +
165 + _lastMessageCount = messages.length;
166 + _lastStreamingLength = streamingLength;
167 + _lastIsStreaming = isStreaming;
168 +
169 return Column(
170 children: [
171 _ModelTopBar(
@@ -245,16 +330,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
330 Expanded(
331 child: Stack(
332 children: [
248 - if (chatState.isLoading)
333 + if (isLoading)
334 const Center(child: CircularProgressIndicator(strokeWidth: 2))
250 - else if (chatState.messages.isEmpty && activeConversation != null)
335 + else if (messages.isEmpty && activeConversation != null)
336 _CorruptedChatState(
337 conversation: activeConversation,
253 - errorMessage: chatState.errorMessage,
338 + errorMessage: errorMessage,
339 onStartNewChat: () =>
340 ref.read(chatProvider.notifier).startNewConversation(),
341 )
257 - else if (chatState.messages.isEmpty)
342 + else if (messages.isEmpty)
343 _EmptyState(
344 onQuickPrompt: (prompt) =>
345 ref.read(chatProvider.notifier).sendMessage(prompt),
@@ -270,11 +355,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
355 _showPersonaPickerForPreselection(context),
356 )
357 else
273 - _MessageList(
358 + _MessageListConsumer(
359 scrollController: _scrollController,
275 - messages: chatState.messages,
276 - streamingMessage: chatState.streamingMessage,
277 - isStreaming: chatState.isStreaming,
360 + messages: messages,
361 + isStreaming: isStreaming,
362 onRetry: (messageId) {
363 ref.read(chatProvider.notifier).retryMessage(messageId);
364 },
@@ -293,7 +377,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
377 }
378 },
379 hasSmartReplies:
296 - !chatState.isStreaming &&
380 + !isStreaming &&
381 MediaQuery.of(context).viewInsets.bottom == 0 &&
382 (ref
383 .watch(smartRepliesProvider)
@@ -323,7 +407,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
407 child: Column(
408 mainAxisSize: MainAxisSize.min,
409 children: [
326 - if (!chatState.isStreaming &&
410 + if (!isStreaming &&
411 MediaQuery.of(context).viewInsets.bottom == 0) ...[
412 const SizedBox(height: 4),
413 _SmartReplyChips(
@@ -336,7 +420,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
420 ],
421 ChatInputBar(
422 focusNode: _inputFocusNode,
339 - isStreaming: chatState.isStreaming,
423 + isStreaming: isStreaming,
424 onSend: (message, {attachments}) {
425 ref
426 .read(chatProvider.notifier)
@@ -620,6 +704,79 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
704 color: Theme.of(context).colorScheme.primary,
705 );
706 }
707 +
708 + void _showToolApprovalDialog(
709 + BuildContext context,
710 + PendingToolApproval approval,
711 + ) {
712 + setState(() {
713 + _isApprovalDialogOpen = true;
714 + });
715 +
716 + final toolCall = approval.toolCall;
717 +
718 + showShadDialog<bool>(
719 + context: context,
720 + barrierDismissible: false,
721 + builder: (context) {
722 + return ShadDialog.alert(
723 + title: Text('Execute Tool: ${toolCall.name}?'),
724 + description: Column(
725 + crossAxisAlignment: CrossAxisAlignment.start,
726 + mainAxisSize: MainAxisSize.min,
727 + children: [
728 + const Text(
729 + 'The model is requesting to execute the following tool:',
730 + ),
731 + const SizedBox(height: 8),
732 + Container(
733 + width: double.infinity,
734 + padding: const EdgeInsets.all(8),
735 + decoration: BoxDecoration(
736 + color: Theme.of(context).colorScheme.surfaceContainer,
737 + borderRadius: BorderRadius.circular(8),
738 + ),
739 + child: SingleChildScrollView(
740 + scrollDirection: Axis.horizontal,
741 + child: Text(
742 + const JsonEncoder.withIndent(
743 + ' ',
744 + ).convert(toolCall.arguments),
745 + style: const TextStyle(
746 + fontFamily: 'monospace',
747 + fontSize: 12,
748 + ),
749 + ),
750 + ),
751 + ),
752 + ],
753 + ),
754 + actions: [
755 + ShadButton.outline(
756 + child: const Text('Reject'),
757 + onPressed: () {
758 + Navigator.of(context).pop(false);
759 + },
760 + ),
761 + ShadButton(
762 + child: const Text('Approve'),
763 + onPressed: () {
764 + Navigator.of(context).pop(true);
765 + },
766 + ),
767 + ],
768 + );
769 + },
770 + ).then((value) {
771 + if (mounted) {
772 + setState(() {
773 + _isApprovalDialogOpen = false;
774 + });
775 + }
776 + final approved = value ?? false;
777 + ref.read(chatProvider.notifier).approveTool(approved);
778 + });
779 + }
780 }
781
782 class _ModelTopBar extends StatelessWidget {
@@ -1147,6 +1304,44 @@ class _RecentConversationItem extends ConsumerWidget {
1304 }
1305 }
1306
1307 +class _MessageListConsumer extends ConsumerWidget {
1308 + const _MessageListConsumer({
1309 + required this.scrollController,
1310 + required this.messages,
1311 + required this.isStreaming,
1312 + required this.onRetry,
1313 + required this.onDelete,
1314 + required this.onEdit,
1315 + this.hasSmartReplies = false,
1316 + });
1317 +
1318 + final ScrollController scrollController;
1319 + final List<Message> messages;
1320 + final bool isStreaming;
1321 + final void Function(String) onRetry;
1322 + final void Function(String) onDelete;
1323 + final void Function(String messageId, String currentContent) onEdit;
1324 + final bool hasSmartReplies;
1325 +
1326 + @override
1327 + Widget build(BuildContext context, WidgetRef ref) {
1328 + final streamingMessage = ref.watch(
1329 + chatProvider.select((s) => s.streamingMessage),
1330 + );
1331 +
1332 + return _MessageList(
1333 + scrollController: scrollController,
1334 + messages: messages,
1335 + streamingMessage: streamingMessage,
1336 + isStreaming: isStreaming,
1337 + onRetry: onRetry,
1338 + onDelete: onDelete,
1339 + onEdit: onEdit,
1340 + hasSmartReplies: hasSmartReplies,
1341 + );
1342 + }
1343 +}
1344 +
1345 class _MessageList extends StatelessWidget {
1346 const _MessageList({
1347 required this.scrollController,
lib/features/chat/views/components/chat_bubble.dart
+196 -94
@@ -1,10 +1,10 @@
1 +import 'dart:async';
2 import 'dart:io';
3 import 'package:flutter/material.dart';
3 -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
4 +import 'package:gpt_markdown/gpt_markdown.dart';
5 import 'package:localmind/l10n/app_localizations.dart';
6 import '../../../../core/models/enums.dart';
7 import '../../data/models/message.dart';
7 -import 'code_block.dart';
8 import 'message_action_bar.dart';
9 import 'processing_indicator.dart';
10 import 'typing_indicator.dart';
@@ -163,17 +163,16 @@ class _UserBubble extends StatelessWidget {
163 isUser: true,
164 ),
165 ),
166 - MarkdownBody(
167 - data: message.content,
168 - selectable: true,
169 - styleSheet: MarkdownStyleSheet(
170 - p: TextStyle(
166 + SelectionArea(
167 + child: _ThemedGptMarkdown(
168 + content: message.content,
169 + isDark: isDark,
170 + style: const TextStyle(
171 color: Colors.white,
172 fontSize: 15,
173 height: 1.4,
174 ),
175 ),
176 - shrinkWrap: true,
176 ),
177 ],
178 ),
@@ -248,6 +247,8 @@ class _AssistantBubble extends StatelessWidget {
247 const ProcessingIndicator()
248 else if (isStreaming && message.content.isEmpty)
249 const TypingIndicator()
250 + else if (isStreaming)
251 + _StreamingContent(content: message.content, isDark: isDark)
252 else
253 _MarkdownContent(content: message.content, isDark: isDark),
254 if (message.status == MessageStatus.error &&
@@ -316,6 +317,84 @@ class _AssistantBubble extends StatelessWidget {
317 }
318 }
319
320 +class _StreamingTextContent extends StatelessWidget {
321 + const _StreamingTextContent({required this.content, required this.isDark});
322 +
323 + final String content;
324 + final bool isDark;
325 +
326 + @override
327 + Widget build(BuildContext context) {
328 + return Text(
329 + content,
330 + style: TextStyle(
331 + color: isDark ? Colors.white : Colors.black,
332 + fontSize: 15,
333 + height: 1.5,
334 + ),
335 + );
336 + }
337 +}
338 +
339 +class _StreamingContent extends StatefulWidget {
340 + const _StreamingContent({required this.content, required this.isDark});
341 +
342 + final String content;
343 + final bool isDark;
344 +
345 + @override
346 + State<_StreamingContent> createState() => _StreamingContentState();
347 +}
348 +
349 +class _StreamingContentState extends State<_StreamingContent> {
350 + static const Duration _updateInterval = Duration(milliseconds: 80);
351 + Timer? _flushTimer;
352 + String _visibleContent = '';
353 +
354 + @override
355 + void initState() {
356 + super.initState();
357 + _visibleContent = widget.content;
358 + }
359 +
360 + @override
361 + void didUpdateWidget(covariant _StreamingContent oldWidget) {
362 + super.didUpdateWidget(oldWidget);
363 + if (oldWidget.content == widget.content) {
364 + return;
365 + }
366 +
367 + if (widget.content.length < _visibleContent.length) {
368 + _flushTimer?.cancel();
369 + _visibleContent = widget.content;
370 + return;
371 + }
372 +
373 + _flushTimer ??= Timer(_updateInterval, () {
374 + _flushTimer = null;
375 + if (mounted) {
376 + setState(() {
377 + _visibleContent = widget.content;
378 + });
379 + }
380 + });
381 + }
382 +
383 + @override
384 + void dispose() {
385 + _flushTimer?.cancel();
386 + super.dispose();
387 + }
388 +
389 + @override
390 + Widget build(BuildContext context) {
391 + return _MarkdownBodyContent(
392 + content: _visibleContent,
393 + isDark: widget.isDark,
394 + );
395 + }
396 +}
397 +
398 class _MarkdownContent extends StatelessWidget {
399 const _MarkdownContent({required this.content, required this.isDark});
400
@@ -324,108 +403,131 @@ class _MarkdownContent extends StatelessWidget {
403
404 @override
405 Widget build(BuildContext context) {
327 - return MarkdownBody(
328 - data: content,
329 - selectable: true,
330 - styleSheet: MarkdownStyleSheet(
331 - p: TextStyle(
406 + if (content.length > 2000) {
407 + return _DeferredMarkdownContent(content: content, isDark: isDark);
408 + }
409 +
410 + return _MarkdownBodyContent(content: content, isDark: isDark);
411 + }
412 +}
413 +
414 +class _MarkdownBodyContent extends StatelessWidget {
415 + const _MarkdownBodyContent({required this.content, required this.isDark});
416 +
417 + final String content;
418 + final bool isDark;
419 +
420 + @override
421 + Widget build(BuildContext context) {
422 + return SelectionArea(
423 + child: _ThemedGptMarkdown(
424 + content: content,
425 + isDark: isDark,
426 + style: TextStyle(
427 color: isDark ? Colors.white : Colors.black,
428 fontSize: 15,
429 height: 1.5,
430 ),
336 - h1: TextStyle(
337 - color: isDark ? Colors.white : Colors.black,
338 - fontSize: 24,
339 - fontWeight: FontWeight.bold,
340 - ),
341 - h2: TextStyle(
342 - color: isDark ? Colors.white : Colors.black,
343 - fontSize: 20,
344 - fontWeight: FontWeight.bold,
345 - ),
346 - h3: TextStyle(
347 - color: isDark ? Colors.white : Colors.black,
348 - fontSize: 18,
349 - fontWeight: FontWeight.w600,
350 - ),
351 - h4: TextStyle(
352 - color: isDark ? Colors.white : Colors.black,
353 - fontSize: 16,
354 - fontWeight: FontWeight.w600,
355 - ),
356 - code: TextStyle(
357 - backgroundColor: isDark
358 - ? const Color(0xFF2D2D2D)
359 - : const Color(0xFFE8E8E8),
360 - color: isDark ? const Color(0xFF9CDCFE) : const Color(0xFF001080),
361 - fontFamily: 'monospace',
362 - fontSize: 13,
363 - ),
364 - codeblockDecoration: BoxDecoration(
365 - color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
366 - borderRadius: BorderRadius.circular(8),
367 - ),
368 - codeblockPadding: const EdgeInsets.all(12),
369 - blockquote: TextStyle(
370 - color: isDark ? const Color(0xFF888888) : const Color(0xFF666666),
371 - fontStyle: FontStyle.italic,
372 - ),
373 - blockquoteDecoration: BoxDecoration(
374 - border: Border(
375 - left: BorderSide(
376 - color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE0E0E0),
377 - width: 3,
378 - ),
379 - ),
380 - ),
381 - blockquotePadding: EdgeInsetsDirectional.only(
382 - start: 16,
383 - ).resolve(Directionality.of(context)),
384 - listBullet: TextStyle(color: isDark ? Colors.white : Colors.black),
385 - tableHead: TextStyle(
386 - color: isDark ? Colors.white : Colors.black,
387 - fontWeight: FontWeight.bold,
388 - ),
389 - tableBody: TextStyle(color: isDark ? Colors.white : Colors.black),
390 - tableBorder: TableBorder.all(
391 - color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE0E0E0),
392 - ),
393 - horizontalRuleDecoration: BoxDecoration(
394 - border: Border(
395 - top: BorderSide(
396 - color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE0E0E0),
397 - ),
398 - ),
399 - ),
400 - a: TextStyle(
401 - color: isDark ? const Color(0xFF60A5FA) : const Color(0xFF2563EB),
402 - decoration: TextDecoration.underline,
403 - ),
431 ),
405 - builders: {'code': CodeBlockBuilder(isDark: isDark)},
432 );
433 }
434 }
435
410 -class CodeBlockBuilder extends MarkdownElementBuilder {
436 +class _ThemedGptMarkdown extends StatelessWidget {
437 + const _ThemedGptMarkdown({
438 + required this.content,
439 + required this.isDark,
440 + required this.style,
441 + });
442 +
443 + final String content;
444 final bool isDark;
445 + final TextStyle style;
446
413 - CodeBlockBuilder({required this.isDark});
447 + @override
448 + Widget build(BuildContext context) {
449 + final gptTheme = GptMarkdownThemeData(
450 + brightness: isDark ? Brightness.dark : Brightness.light,
451 + highlightColor: isDark
452 + ? const Color(0xFF334155)
453 + : const Color(0xFFDBEAFE),
454 + linkColor: isDark ? const Color(0xFF60A5FA) : const Color(0xFF2563EB),
455 + linkHoverColor: isDark
456 + ? const Color(0xFF93C5FD)
457 + : const Color(0xFF1D4ED8),
458 + hrLineColor: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
459 + hrLineThickness: 1.0,
460 + h1: style.copyWith(fontSize: 24, fontWeight: FontWeight.w700),
461 + h2: style.copyWith(fontSize: 21, fontWeight: FontWeight.w700),
462 + h3: style.copyWith(fontSize: 19, fontWeight: FontWeight.w600),
463 + h4: style.copyWith(fontSize: 17, fontWeight: FontWeight.w600),
464 + h5: style.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
465 + h6: style.copyWith(fontSize: 15, fontWeight: FontWeight.w600),
466 + );
467 +
468 + return GptMarkdownTheme(
469 + gptThemeData: gptTheme,
470 + child: GptMarkdown(content, style: style, followLinkColor: true),
471 + );
472 + }
473 +}
474 +
475 +class _DeferredMarkdownContent extends StatefulWidget {
476 + const _DeferredMarkdownContent({required this.content, required this.isDark});
477 +
478 + final String content;
479 + final bool isDark;
480 +
481 + @override
482 + State<_DeferredMarkdownContent> createState() =>
483 + _DeferredMarkdownContentState();
484 +}
485 +
486 +class _DeferredMarkdownContentState extends State<_DeferredMarkdownContent> {
487 + bool _showMarkdown = false;
488 + Timer? _deferTimer;
489
490 @override
416 - Widget? visitElementAfter(element, TextStyle? preferredStyle) {
417 - final code = element.textContent;
418 - String? language;
419 -
420 - if (element.attributes.containsKey('class')) {
421 - final classes = element.attributes['class']!;
422 - final langMatch = RegExp(r'language-(\w+)').firstMatch(classes);
423 - if (langMatch != null) {
424 - language = langMatch.group(1);
491 + void initState() {
492 + super.initState();
493 + _deferTimer = Timer(const Duration(milliseconds: 120), () {
494 + if (mounted) {
495 + setState(() => _showMarkdown = true);
496 }
497 + });
498 + }
499 +
500 + @override
501 + void dispose() {
502 + _deferTimer?.cancel();
503 + super.dispose();
504 + }
505 +
506 + @override
507 + void didUpdateWidget(_DeferredMarkdownContent oldWidget) {
508 + super.didUpdateWidget(oldWidget);
509 + if (oldWidget.content != widget.content ||
510 + oldWidget.isDark != widget.isDark) {
511 + _showMarkdown = false;
512 + _deferTimer?.cancel();
513 + _deferTimer = Timer(const Duration(milliseconds: 120), () {
514 + if (mounted) {
515 + setState(() => _showMarkdown = true);
516 + }
517 + });
518 + }
519 + }
520 +
521 + @override
522 + Widget build(BuildContext context) {
523 + if (!_showMarkdown) {
524 + return _StreamingTextContent(
525 + content: widget.content,
526 + isDark: widget.isDark,
527 + );
528 }
529
428 - return CodeBlock(code: code, language: language);
530 + return _MarkdownBodyContent(content: widget.content, isDark: widget.isDark);
531 }
532 }
533
lib/features/chat/views/components/code_block.dart deleted
-259
@@ -1,259 +0,0 @@
1 -import 'package:flutter/material.dart';
2 -import 'package:flutter/services.dart';
3 -import 'package:flutter_riverpod/flutter_riverpod.dart';
4 -import 'package:syntax_highlight/syntax_highlight.dart';
5 -import 'package:localmind/l10n/app_localizations.dart';
6 -import '../../../../core/providers/highlighter_provider.dart';
7 -
8 -class CodeBlock extends ConsumerWidget {
9 - const CodeBlock({super.key, required this.code, this.language});
10 -
11 - final String code;
12 - final String? language;
13 -
14 - String _mapLanguage(String? lang) {
15 - if (lang == null || lang.isEmpty) return '';
16 -
17 - final lower = lang.toLowerCase();
18 - switch (lower) {
19 - case 'dart':
20 - case 'flutter':
21 - return 'dart';
22 - case 'c':
23 - return 'c';
24 - case 'cpp':
25 - case 'c++':
26 - case 'cxx':
27 - return 'cpp';
28 - case 'csharp':
29 - case 'cs':
30 - return 'csharp';
31 - case 'go':
32 - case 'golang':
33 - return 'go';
34 - case 'java':
35 - return 'java';
36 - case 'javascript':
37 - case 'js':
38 - return 'javascript';
39 - case 'typescript':
40 - case 'ts':
41 - return 'typescript';
42 - case 'kotlin':
43 - case 'kt':
44 - return 'kotlin';
45 - case 'lua':
46 - return 'lua';
47 - case 'python':
48 - case 'py':
49 - return 'python';
50 - case 'rust':
51 - case 'rs':
52 - return 'rust';
53 - case 'swift':
54 - return 'swift';
55 - case 'yaml':
56 - case 'yml':
57 - return 'yaml';
58 - case 'html':
59 - case 'htm':
60 - return 'html';
61 - case 'css':
62 - return 'css';
63 - case 'json':
64 - return 'json';
65 - case 'sql':
66 - return 'sql';
67 - default:
68 - return lower;
69 - }
70 - }
71 -
72 - String _normalizeLanguage(String? lang) {
73 - if (lang == null || lang.isEmpty) return '';
74 - return lang.toUpperCase();
75 - }
76 -
77 - @override
78 - Widget build(BuildContext context, WidgetRef ref) {
79 - final theme = Theme.of(context);
80 - final isDark = theme.brightness == Brightness.dark;
81 - final themes = ref.watch(highlighterThemesProvider);
82 -
83 - final languageName = _mapLanguage(language);
84 -
85 - final backgroundColor = isDark
86 - ? const Color(0xFF1E1E1E)
87 - : const Color(0xFFF5F5F5);
88 -
89 - final headerBgColor = isDark
90 - ? const Color(0xFF2D2D2D)
91 - : const Color(0xFFE8E8E8);
92 -
93 - final borderColor = isDark
94 - ? const Color(0xFF3A3A3A)
95 - : const Color(0xFFE0E0E0);
96 -
97 - return Container(
98 - decoration: BoxDecoration(
99 - color: backgroundColor,
100 - borderRadius: BorderRadius.circular(8),
101 - border: Border.all(color: borderColor),
102 - ),
103 - child: Column(
104 - crossAxisAlignment: CrossAxisAlignment.stretch,
105 - children: [
106 - Container(
107 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
108 - decoration: BoxDecoration(
109 - color: headerBgColor,
110 - borderRadius: const BorderRadius.vertical(
111 - top: Radius.circular(7),
112 - ),
113 - ),
114 - child: Row(
115 - mainAxisAlignment: MainAxisAlignment.spaceBetween,
116 - children: [
117 - Text(
118 - _normalizeLanguage(language),
119 - style: TextStyle(
120 - fontSize: 11,
121 - fontWeight: FontWeight.w600,
122 - color: isDark
123 - ? const Color(0xFF888888)
124 - : const Color(0xFF666666),
125 - fontFamily: 'monospace',
126 - ),
127 - ),
128 - _CopyButton(code: code, isDark: isDark),
129 - ],
130 - ),
131 - ),
132 - themes.when(
133 - data: (themesData) {
134 - final loadedTheme = isDark ? themesData.dark : themesData.light;
135 - final effectiveLanguage = languageName.isNotEmpty ? languageName : 'dart';
136 -
137 - if (supportedHighlighterLanguages.contains(effectiveLanguage)) {
138 - final highlighter = Highlighter(
139 - language: effectiveLanguage,
140 - theme: loadedTheme,
141 - );
142 - return SingleChildScrollView(
143 - scrollDirection: Axis.horizontal,
144 - padding: const EdgeInsets.all(12),
145 - child: SelectableText.rich(
146 - TextSpan(
147 - style: const TextStyle(
148 - fontFamily: 'monospace',
149 - fontSize: 13,
150 - height: 1.5,
151 - ),
152 - children: <TextSpan>[
153 - highlighter.highlight(code),
154 - ],
155 - ),
156 - ),
157 - );
158 - } else {
159 - return SingleChildScrollView(
160 - scrollDirection: Axis.horizontal,
161 - padding: const EdgeInsets.all(12),
162 - child: SelectableText(
163 - code,
164 - style: const TextStyle(
165 - fontFamily: 'monospace',
166 - fontSize: 13,
167 - height: 1.5,
168 - ),
169 - ),
170 - );
171 - }
172 - },
173 - loading: () => SingleChildScrollView(
174 - scrollDirection: Axis.horizontal,
175 - padding: const EdgeInsets.all(12),
176 - child: SelectableText(
177 - code,
178 - style: const TextStyle(
179 - fontFamily: 'monospace',
180 - fontSize: 13,
181 - height: 1.5,
182 - ),
183 - ),
184 - ),
185 - error: (e, _) => SingleChildScrollView(
186 - scrollDirection: Axis.horizontal,
187 - padding: const EdgeInsets.all(12),
188 - child: SelectableText(
189 - code,
190 - style: const TextStyle(
191 - fontFamily: 'monospace',
192 - fontSize: 13,
193 - height: 1.5,
194 - ),
195 - ),
196 - ),
197 - ),
198 - ],
199 - ),
200 - );
201 - }
202 -}
203 -
204 -class _CopyButton extends StatefulWidget {
205 - const _CopyButton({required this.code, required this.isDark});
206 -
207 - final String code;
208 - final bool isDark;
209 -
210 - @override
211 - State<_CopyButton> createState() => _CopyButtonState();
212 -}
213 -
214 -class _CopyButtonState extends State<_CopyButton> {
215 - bool _copied = false;
216 -
217 - Future<void> _copyToClipboard() async {
218 - await Clipboard.setData(ClipboardData(text: widget.code));
219 - setState(() => _copied = true);
220 - await Future.delayed(const Duration(seconds: 2));
221 - if (mounted) {
222 - setState(() => _copied = false);
223 - }
224 - }
225 -
226 - @override
227 - Widget build(BuildContext context) {
228 - final l10n = AppLocalizations.of(context)!;
229 - return GestureDetector(
230 - onTap: _copyToClipboard,
231 - child: Row(
232 - mainAxisSize: MainAxisSize.min,
233 - children: [
234 - Icon(
235 - _copied ? Icons.check : Icons.copy,
236 - size: 14,
237 - color: _copied
238 - ? Colors.green
239 - : (widget.isDark
240 - ? const Color(0xFF888888)
241 - : const Color(0xFF666666)),
242 - ),
243 - const SizedBox(width: 4),
244 - Text(
245 - _copied ? l10n.copied : l10n.copy,
246 - style: TextStyle(
247 - fontSize: 11,
248 - color: _copied
249 - ? Colors.green
250 - : (widget.isDark
251 - ? const Color(0xFF888888)
252 - : const Color(0xFF666666)),
253 - ),
254 - ),
255 - ],
256 - ),
257 - );
258 - }
259 -}
lib/features/chat/views/components/reasoning_widget.dart
+13 -9
@@ -33,15 +33,16 @@ class _ReasoningWidgetState extends State<ReasoningWidget> {
33 }
34
35 String _getLastLines(String content, int maxLines) {
36 - final lines = content
37 - .split('\n')
38 - .where((line) => line.trim().isNotEmpty)
39 - .toList();
40 - if (lines.length <= maxLines) {
41 - return content.trim();
36 + var lineCount = 0;
37 + for (var i = content.length - 1; i >= 0; i--) {
38 + if (content.codeUnitAt(i) == 10) {
39 + lineCount++;
40 + if (lineCount >= maxLines) {
41 + return '... ${content.substring(i + 1).trim()}';
42 + }
43 + }
44 }
43 - final lastLines = lines.reversed.take(maxLines).toList().reversed;
44 - return '... ${lastLines.join('\n')}';
45 + return content.trim();
46 }
47
48 @override
@@ -58,6 +59,9 @@ class _ReasoningWidgetState extends State<ReasoningWidget> {
59 final previewText = hasContent
60 ? _getLastLines(widget.reasoningContent!, 4)
61 : '';
62 + final visibleReasoningText = widget.isStreaming
63 + ? previewText
64 + : (_isExpanded ? widget.reasoningContent! : previewText);
65
66 return GestureDetector(
67 onTap: () => setState(() => _isExpanded = !_isExpanded),
@@ -124,7 +128,7 @@ class _ReasoningWidgetState extends State<ReasoningWidget> {
128 if (hasContent) ...[
129 const SizedBox(height: 8),
130 Text(
127 - _isExpanded ? widget.reasoningContent! : previewText,
131 + visibleReasoningText,
132 style: TextStyle(
133 fontSize: 13,
134 color: isDark
lib/features/conversations/providers/conversation_providers.dart
+12 -5
@@ -18,19 +18,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
18
19 Future<List<Conversation>> _loadAll() async {
20 final db = ref.read(databaseProvider);
21 - final entities = db.conversationBox.getAll();
22 - final conversations = entities.map((e) => e.toDomain()).toList();
23 - _sortConversations(conversations);
24 - return conversations;
21 + return await db.store.runInTransactionAsync(
22 + TxMode.read,
23 + _loadConversationsInBackground,
24 + null,
25 + );
26 }
27
27 - void _sortConversations(List<Conversation> conversations) {
28 + static List<Conversation> _loadConversationsInBackground(Store store, _) {
29 + final convBox = store.box<ConversationEntity>();
30 + final entities = convBox.getAll();
31 + final conversations = entities.map((e) => e.toDomain()).toList();
32 +
33 conversations.sort((a, b) {
34 if (a.isPinned != b.isPinned) {
35 return a.isPinned ? -1 : 1;
36 }
37 return b.updatedAt.compareTo(a.updatedAt);
38 });
39 +
40 + return conversations;
41 }
42
43 Future<Conversation> createConversation({
lib/features/mcp/views/mcp_tools_screen.dart new
+381
@@ -0,0 +1,381 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:hugeicons/hugeicons.dart';
4 +import 'package:shadcn_ui/shadcn_ui.dart';
5 +
6 +import '../../chat/data/mcp_server_manager.dart';
7 +import '../../chat/data/tools/tool_definition.dart';
8 +import '../../chat/providers/tooling_providers.dart';
9 +
10 +class McpToolsScreen extends ConsumerStatefulWidget {
11 + const McpToolsScreen({super.key});
12 +
13 + @override
14 + ConsumerState<McpToolsScreen> createState() => _McpToolsScreenState();
15 +}
16 +
17 +class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
18 + Future<List<ToolDefinition>>? _toolsFuture;
19 +
20 + @override
21 + void initState() {
22 + super.initState();
23 + _toolsFuture = _loadTools();
24 + }
25 +
26 + Future<List<ToolDefinition>> _loadTools() {
27 + return ref.read(toolRegistryProvider).listTools();
28 + }
29 +
30 + void _refreshTools() {
31 + ref.invalidate(toolRegistryProvider);
32 + setState(() {
33 + _toolsFuture = _loadTools();
34 + });
35 + }
36 +
37 + void _toggleExampleServer() {
38 + final manager = ref.read(mcpServerManagerProvider);
39 + if (manager.hasExampleServer()) {
40 + manager.removeServer(exampleMcpServerLabel);
41 + } else {
42 + manager.addExampleServer();
43 + }
44 + _refreshTools();
45 + }
46 +
47 + @override
48 + Widget build(BuildContext context) {
49 + final theme = Theme.of(context);
50 + final isDark = theme.brightness == Brightness.dark;
51 + final manager = ref.watch(mcpServerManagerProvider);
52 + final hasExampleServer = manager.hasExampleServer();
53 +
54 + return SafeArea(
55 + child: Column(
56 + children: [
57 + Container(
58 + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
59 + decoration: BoxDecoration(
60 + color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
61 + border: Border(
62 + bottom: BorderSide(
63 + color: isDark
64 + ? const Color(0xFF2A2A2A)
65 + : const Color(0xFFE5E5E5),
66 + ),
67 + ),
68 + ),
69 + child: Row(
70 + children: [
71 + Builder(
72 + builder: (context) => IconButton(
73 + icon: const Icon(Icons.menu),
74 + onPressed: () => Scaffold.of(context).openDrawer(),
75 + ),
76 + ),
77 + const SizedBox(width: 8),
78 + const HugeIcon(
79 + icon: HugeIcons.strokeRoundedMcpServer,
80 + size: 22,
81 + ),
82 + const SizedBox(width: 10),
83 + Text(
84 + 'MCP Tools',
85 + style: theme.textTheme.titleLarge?.copyWith(
86 + fontWeight: FontWeight.bold,
87 + ),
88 + ),
89 + ],
90 + ),
91 + ),
92 + Expanded(
93 + child: RefreshIndicator(
94 + onRefresh: () async => _refreshTools(),
95 + child: ListView(
96 + padding: const EdgeInsets.all(16),
97 + children: [
98 + _ExampleServerPanel(
99 + enabled: hasExampleServer,
100 + onToggle: _toggleExampleServer,
101 + ),
102 + const SizedBox(height: 20),
103 + Text(
104 + 'Available tools',
105 + style: theme.textTheme.titleMedium?.copyWith(
106 + fontWeight: FontWeight.w700,
107 + ),
108 + ),
109 + const SizedBox(height: 12),
110 + FutureBuilder<List<ToolDefinition>>(
111 + future: _toolsFuture,
112 + builder: (context, snapshot) {
113 + if (snapshot.connectionState == ConnectionState.waiting) {
114 + return const Center(
115 + child: Padding(
116 + padding: EdgeInsets.all(32),
117 + child: CircularProgressIndicator(),
118 + ),
119 + );
120 + }
121 +
122 + if (snapshot.hasError) {
123 + return _StatusPanel(
124 + icon: Icons.error_outline,
125 + title: 'Unable to load tools',
126 + body: snapshot.error.toString(),
127 + );
128 + }
129 +
130 + final tools = snapshot.data ?? const <ToolDefinition>[];
131 + if (tools.isEmpty) {
132 + return const _StatusPanel(
133 + icon: Icons.extension_outlined,
134 + title: 'No tools registered',
135 + body:
136 + 'Enable the example MCP server or add MCP integrations from chat settings.',
137 + );
138 + }
139 +
140 + return Column(
141 + children: tools
142 + .map((tool) => _ToolRow(tool: tool))
143 + .toList(growable: false),
144 + );
145 + },
146 + ),
147 + ],
148 + ),
149 + ),
150 + ),
151 + ],
152 + ),
153 + );
154 + }
155 +}
156 +
157 +class _ExampleServerPanel extends StatelessWidget {
158 + const _ExampleServerPanel({required this.enabled, required this.onToggle});
159 +
160 + final bool enabled;
161 + final VoidCallback onToggle;
162 +
163 + @override
164 + Widget build(BuildContext context) {
165 + final theme = Theme.of(context);
166 + final isDark = theme.brightness == Brightness.dark;
167 +
168 + return Container(
169 + padding: const EdgeInsets.all(16),
170 + decoration: BoxDecoration(
171 + color: isDark ? const Color(0xFF141414) : const Color(0xFFF8FAFC),
172 + borderRadius: BorderRadius.circular(8),
173 + border: Border.all(
174 + color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFE2E8F0),
175 + ),
176 + ),
177 + child: Column(
178 + crossAxisAlignment: CrossAxisAlignment.start,
179 + children: [
180 + Row(
181 + crossAxisAlignment: CrossAxisAlignment.start,
182 + children: [
183 + Container(
184 + width: 40,
185 + height: 40,
186 + alignment: Alignment.center,
187 + decoration: BoxDecoration(
188 + color: theme.colorScheme.primary.withValues(alpha: 0.12),
189 + borderRadius: BorderRadius.circular(8),
190 + ),
191 + child: HugeIcon(
192 + icon: HugeIcons.strokeRoundedMcpServer,
193 + color: theme.colorScheme.primary,
194 + size: 22,
195 + ),
196 + ),
197 + const SizedBox(width: 12),
198 + Expanded(
199 + child: Column(
200 + crossAxisAlignment: CrossAxisAlignment.start,
201 + children: [
202 + Text(
203 + 'Example MCP server',
204 + style: theme.textTheme.titleMedium?.copyWith(
205 + fontWeight: FontWeight.w700,
206 + ),
207 + ),
208 + const SizedBox(height: 4),
209 + Text(
210 + 'Registers example.echo and example.word_count through the same MCP tool provider used by external servers.',
211 + style: theme.textTheme.bodyMedium?.copyWith(
212 + color: theme.colorScheme.onSurfaceVariant,
213 + ),
214 + ),
215 + ],
216 + ),
217 + ),
218 + ],
219 + ),
220 + const SizedBox(height: 16),
221 + ShadButton(
222 + onPressed: onToggle,
223 + leading: Icon(enabled ? Icons.power_settings_new : Icons.add),
224 + child: Text(
225 + enabled ? 'Disable example server' : 'Enable example server',
226 + ),
227 + ),
228 + ],
229 + ),
230 + );
231 + }
232 +}
233 +
234 +class _ToolRow extends StatelessWidget {
235 + const _ToolRow({required this.tool});
236 +
237 + final ToolDefinition tool;
238 +
239 + @override
240 + Widget build(BuildContext context) {
241 + final theme = Theme.of(context);
242 + final isMcp = tool.providerType == ToolProviderType.mcp;
243 + final isDark = theme.brightness == Brightness.dark;
244 +
245 + return Container(
246 + margin: const EdgeInsets.only(bottom: 10),
247 + padding: const EdgeInsets.all(14),
248 + decoration: BoxDecoration(
249 + color: isDark ? const Color(0xFF111111) : Colors.white,
250 + borderRadius: BorderRadius.circular(8),
251 + border: Border.all(
252 + color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFE5E7EB),
253 + ),
254 + ),
255 + child: Row(
256 + crossAxisAlignment: CrossAxisAlignment.start,
257 + children: [
258 + Icon(
259 + isMcp ? Icons.hub_outlined : Icons.functions,
260 + size: 20,
261 + color: isMcp
262 + ? theme.colorScheme.primary
263 + : theme.colorScheme.outline,
264 + ),
265 + const SizedBox(width: 12),
266 + Expanded(
267 + child: Column(
268 + crossAxisAlignment: CrossAxisAlignment.start,
269 + children: [
270 + Text(
271 + tool.name,
272 + style: theme.textTheme.bodyLarge?.copyWith(
273 + fontWeight: FontWeight.w700,
274 + ),
275 + ),
276 + if (tool.description.isNotEmpty) ...[
277 + const SizedBox(height: 4),
278 + Text(
279 + tool.description,
280 + style: theme.textTheme.bodyMedium?.copyWith(
281 + color: theme.colorScheme.onSurfaceVariant,
282 + ),
283 + ),
284 + ],
285 + const SizedBox(height: 8),
286 + Wrap(
287 + spacing: 8,
288 + runSpacing: 8,
289 + children: [
290 + _ToolBadge(
291 + label: isMcp ? 'MCP' : 'Built-in',
292 + color: isMcp
293 + ? theme.colorScheme.primary
294 + : theme.colorScheme.outline,
295 + ),
296 + if (tool.providerRef != null)
297 + _ToolBadge(
298 + label: tool.providerRef!,
299 + color: theme.colorScheme.secondary,
300 + ),
301 + ],
302 + ),
303 + ],
304 + ),
305 + ),
306 + ],
307 + ),
308 + );
309 + }
310 +}
311 +
312 +class _ToolBadge extends StatelessWidget {
313 + const _ToolBadge({required this.label, required this.color});
314 +
315 + final String label;
316 + final Color color;
317 +
318 + @override
319 + Widget build(BuildContext context) {
320 + return Container(
321 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
322 + decoration: BoxDecoration(
323 + color: color.withValues(alpha: 0.12),
324 + borderRadius: BorderRadius.circular(6),
325 + ),
326 + child: Text(
327 + label,
328 + style: TextStyle(
329 + color: color,
330 + fontSize: 12,
331 + fontWeight: FontWeight.w600,
332 + ),
333 + ),
334 + );
335 + }
336 +}
337 +
338 +class _StatusPanel extends StatelessWidget {
339 + const _StatusPanel({
340 + required this.icon,
341 + required this.title,
342 + required this.body,
343 + });
344 +
345 + final IconData icon;
346 + final String title;
347 + final String body;
348 +
349 + @override
350 + Widget build(BuildContext context) {
351 + final theme = Theme.of(context);
352 + return Container(
353 + padding: const EdgeInsets.all(24),
354 + decoration: BoxDecoration(
355 + borderRadius: BorderRadius.circular(8),
356 + border: Border.all(color: theme.colorScheme.outline),
357 + ),
358 + child: Column(
359 + children: [
360 + Icon(icon, size: 32, color: theme.colorScheme.outline),
361 + const SizedBox(height: 12),
362 + Text(
363 + title,
364 + style: theme.textTheme.titleMedium?.copyWith(
365 + fontWeight: FontWeight.w700,
366 + ),
367 + textAlign: TextAlign.center,
368 + ),
369 + const SizedBox(height: 6),
370 + Text(
371 + body,
372 + style: theme.textTheme.bodyMedium?.copyWith(
373 + color: theme.colorScheme.onSurfaceVariant,
374 + ),
375 + textAlign: TextAlign.center,
376 + ),
377 + ],
378 + ),
379 + );
380 + }
381 +}
lib/features/on_device/data/on_device_chat_service.dart
+80 -39
@@ -17,6 +17,8 @@ class OnDeviceChatService implements ChatService {
17 StreamSubscription<gemma.ModelResponse>? _currentSubscription;
18 StreamController<ChatResponse>? _streamController;
19 gemma.InferenceChat? _activeChat;
20 + String? _activeModelId;
21 + String? _activeSystemInstruction;
22 bool _isCancelled = false;
23
24 OnDeviceChatService(this._gemmaService);
@@ -61,48 +63,77 @@ class OnDeviceChatService implements ChatService {
63 ? params.systemPrompt
64 : null;
65
64 - final chat = await _gemmaService.createChat(
65 - systemInstruction: systemInstruction,
66 - tools: const [], // will be populated in Task 6
67 - );
66 + final gemmaMessages = _convertMessages(messages);
67
69 - if (chat == null) {
70 - _streamController?.add(
71 - const ChatResponse(
72 - type: ChatResponseType.error,
73 - content: 'Failed to create chat session',
74 - ),
75 - );
76 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
77 - await _streamController?.close();
78 - return;
68 + bool canReuse = false;
69 + List<gemma.Message> newMessagesToFeed = [];
70 +
71 + if (_activeChat != null &&
72 + _activeModelId == modelId &&
73 + _activeSystemInstruction == systemInstruction) {
74 + final existingHistory = _activeChat!.fullHistory;
75 + if (existingHistory.length <= gemmaMessages.length) {
76 + bool isPrefix = true;
77 + for (int i = 0; i < existingHistory.length; i++) {
78 + if (existingHistory[i].text != gemmaMessages[i].text ||
79 + existingHistory[i].isUser != gemmaMessages[i].isUser) {
80 + isPrefix = false;
81 + break;
82 + }
83 + }
84 + if (isPrefix) {
85 + canReuse = true;
86 + newMessagesToFeed = gemmaMessages.sublist(existingHistory.length);
87 + }
88 + }
89 }
90
81 - _activeChat = chat;
91 + gemma.InferenceChat chat;
92 + if (canReuse) {
93 + chat = _activeChat!;
94 + Log.debug('Reusing existing active chat session for on-device inference.');
95 + for (final msg in newMessagesToFeed) {
96 + if (_isCancelled) break;
97 + await chat.addQueryChunk(msg);
98 + }
99 + } else {
100 + Log.debug('Creating new chat session for on-device inference.');
101 + if (_activeChat != null) {
102 + _disposeChat(_activeChat!);
103 + _activeChat = null;
104 + }
105 +
106 + final newChat = await _gemmaService.createChat(
107 + systemInstruction: systemInstruction,
108 + tools: const [],
109 + );
110
83 - // Convert and add all messages except the last user message to context
84 - final gemmaMessages = _convertMessages(messages);
111 + if (newChat == null) {
112 + _streamController?.add(
113 + const ChatResponse(
114 + type: ChatResponseType.error,
115 + content: 'Failed to create chat session',
116 + ),
117 + );
118 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
119 + await _streamController?.close();
120 + return;
121 + }
122
86 - if (_isCancelled) {
87 - _disposeChat(chat);
88 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
89 - await _streamController?.close();
90 - return;
91 - }
123 + chat = newChat;
124 + _activeChat = chat;
125 + _activeModelId = modelId;
126 + _activeSystemInstruction = systemInstruction;
127
93 - // Add all messages except the last one as context
94 - if (gemmaMessages.length > 1) {
95 - for (final msg in gemmaMessages.sublist(0, gemmaMessages.length - 1)) {
128 + for (final msg in gemmaMessages) {
129 + if (_isCancelled) break;
130 await chat.addQueryChunk(msg);
131 }
132 }
133
100 - // Add the last user message
101 - final lastMessage = gemmaMessages.last;
102 - await chat.addQueryChunk(lastMessage);
103 -
134 if (_isCancelled) {
135 _disposeChat(chat);
136 + _activeChat = null;
137 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
138 await _streamController?.close();
139 return;
@@ -184,10 +215,8 @@ class OnDeviceChatService implements ChatService {
215 _streamController?.add(
216 const ChatResponse(type: ChatResponseType.done),
217 );
187 - _disposeChat(chat);
218 _streamController?.close();
219 }
190 - _activeChat = null;
220 _currentSubscription = null;
221 if (!completer.isCompleted) completer.complete();
222 },
@@ -206,7 +235,9 @@ class OnDeviceChatService implements ChatService {
235 _streamController?.close();
236 }
237 _disposeChat(chat);
209 - _activeChat = null;
238 + if (_activeChat == chat) {
239 + _activeChat = null;
240 + }
241 _currentSubscription = null;
242 if (!completer.isCompleted) completer.complete();
243 },
@@ -236,7 +267,13 @@ class OnDeviceChatService implements ChatService {
267 }
268
269 List<gemma.Message> _convertMessages(List<Message> messages) {
239 - return messages
270 + var list = messages;
271 + if (list.isNotEmpty &&
272 + list.last.role == MessageRole.assistant &&
273 + list.last.content.isEmpty) {
274 + list = list.sublist(0, list.length - 1);
275 + }
276 + return list
277 .where(
278 (m) =>
279 m.role == MessageRole.user ||
@@ -259,12 +296,9 @@ class OnDeviceChatService implements ChatService {
296 _currentSubscription?.cancel();
297 _currentSubscription = null;
298
262 - final chat = _activeChat;
263 - _activeChat = null;
264 - if (chat != null) {
265 - chat.stopGeneration().then((_) => _disposeChat(chat)).catchError((e) {
299 + if (_activeChat != null) {
300 + _activeChat!.stopGeneration().catchError((e) {
301 Log.error('Error stopping generation: $e');
267 - _disposeChat(chat);
302 });
303 }
304
@@ -273,4 +307,11 @@ class OnDeviceChatService implements ChatService {
307 _streamController?.close();
308 }
309 }
310 +
311 + void dispose() {
312 + if (_activeChat != null) {
313 + _disposeChat(_activeChat!);
314 + _activeChat = null;
315 + }
316 + }
317 }
lib/features/on_device/providers/on_device_providers.dart
+3
@@ -89,6 +89,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
89 await bgService.start();
90
91 try {
92 + // Yield control to the event loop so the UI has time to draw the loading indicator
93 + // and stabilize before the native platform thread begins heavy model loading.
94 + await Future.delayed(const Duration(milliseconds: 100));
95 final isInstalled = await _gemmaService.isModelInstalled(modelId);
96 if (!isInstalled) {
97 state = state.copyWith(
lib/features/sidebar/components/drawer_nav_item.dart
+36 -8
@@ -8,12 +8,14 @@ class DrawerNavItem extends StatelessWidget {
8 required this.label,
9 required this.isSelected,
10 required this.onTap,
11 + this.badgeText,
12 });
13
14 final List<List<dynamic>> iconData;
15 final String label;
16 final bool isSelected;
17 final VoidCallback onTap;
18 + final String? badgeText;
19
20 @override
21 Widget build(BuildContext context) {
@@ -45,16 +47,42 @@ class DrawerNavItem extends StatelessWidget {
47 : (isDark ? Colors.white70 : Colors.black54),
48 ),
49 const SizedBox(width: 16),
48 - Text(
49 - label,
50 - style: TextStyle(
51 - fontSize: 14,
52 - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
53 - color: isSelected
54 - ? (isDark ? Colors.white : theme.colorScheme.primary)
55 - : (isDark ? Colors.white70 : Colors.black87),
50 + Expanded(
51 + child: Text(
52 + label,
53 + style: TextStyle(
54 + fontSize: 14,
55 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
56 + color: isSelected
57 + ? (isDark ? Colors.white : theme.colorScheme.primary)
58 + : (isDark ? Colors.white70 : Colors.black87),
59 + ),
60 + overflow: TextOverflow.ellipsis,
61 ),
62 ),
63 + if (badgeText != null && badgeText!.isNotEmpty) ...[
64 + const SizedBox(width: 8),
65 + Container(
66 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
67 + decoration: BoxDecoration(
68 + color: isDark
69 + ? theme.colorScheme.primary.withAlpha(45)
70 + : theme.colorScheme.primary.withAlpha(20),
71 + borderRadius: BorderRadius.circular(999),
72 + ),
73 + child: Text(
74 + badgeText!,
75 + style: TextStyle(
76 + fontSize: 10,
77 + fontWeight: FontWeight.w700,
78 + letterSpacing: 0.3,
79 + color: isSelected
80 + ? theme.colorScheme.primary
81 + : (isDark ? Colors.white : theme.colorScheme.primary),
82 + ),
83 + ),
84 + ),
85 + ],
86 ],
87 ),
88 ),
lib/features/sidebar/sidebar_widget.dart
+13
@@ -25,6 +25,7 @@ class SidebarWidget extends ConsumerWidget {
25
26 final isHistory = location.startsWith(AppRoutes.chatHistory);
27 final isServers = location.startsWith(AppRoutes.servers);
28 + final isMcpTools = location.startsWith(AppRoutes.mcpTools);
29 final isPersonas = location.startsWith(AppRoutes.personas);
30 final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
31 final isTtsModels = location.startsWith(AppRoutes.ttsModels);
@@ -92,6 +93,18 @@ class SidebarWidget extends ConsumerWidget {
93 context.go(AppRoutes.servers);
94 },
95 ),
96 + DrawerNavItem(
97 + iconData: HugeIcons.strokeRoundedMcpServer,
98 + label: 'MCP Tools',
99 + badgeText: 'Beta',
100 + isSelected: isMcpTools,
101 + onTap: () {
102 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
103 + Navigator.pop(context);
104 + }
105 + context.go(AppRoutes.mcpTools);
106 + },
107 + ),
108 DrawerNavItem(
109 iconData: HugeIcons.strokeRoundedSmartPhone01,
110 label: l10n.nav_local_models,
pubspec.lock
+37 -13
@@ -459,14 +459,14 @@ packages:
459 description: flutter
460 source: sdk
461 version: "0.0.0"
462 - flutter_markdown_plus:
463 - dependency: "direct main"
462 + flutter_math_fork:
463 + dependency: transitive
464 description:
465 - name: flutter_markdown_plus
466 - sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
465 + name: flutter_math_fork
466 + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
467 url: "https://pub.dev"
468 source: hosted
469 - version: "1.0.7"
469 + version: "0.7.4"
470 flutter_plugin_android_lifecycle:
471 dependency: transitive
472 description:
@@ -597,6 +597,14 @@ packages:
597 url: "https://pub.dev"
598 source: hosted
599 version: "6.3.3"
600 + gpt_markdown:
601 + dependency: "direct main"
602 + description:
603 + name: gpt_markdown
604 + sha256: c14c2a4599a67df5b6a984808cbb7631b8d15c91984ffdd7998597b93a6ff136
605 + url: "https://pub.dev"
606 + source: hosted
607 + version: "1.1.7"
608 graphs:
609 dependency: transitive
610 description:
@@ -789,14 +797,6 @@ packages:
797 url: "https://pub.dev"
798 source: hosted
799 version: "3.1.14"
792 - markdown:
793 - dependency: transitive
794 - description:
795 - name: markdown
796 - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
797 - url: "https://pub.dev"
798 - source: hosted
799 - version: "7.3.1"
800 matcher:
801 dependency: transitive
802 description:
@@ -837,6 +837,14 @@ packages:
837 url: "https://pub.dev"
838 source: hosted
839 version: "0.17.6"
840 + nested:
841 + dependency: transitive
842 + description:
843 + name: nested
844 + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
845 + url: "https://pub.dev"
846 + source: hosted
847 + version: "1.0.0"
848 node_preamble:
849 dependency: transitive
850 description:
@@ -1013,6 +1021,14 @@ packages:
1021 url: "https://pub.dev"
1022 source: hosted
1023 version: "6.5.0"
1024 + provider:
1025 + dependency: transitive
1026 + description:
1027 + name: provider
1028 + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
1029 + url: "https://pub.dev"
1030 + source: hosted
1031 + version: "6.1.5+1"
1032 pub_semver:
1033 dependency: transitive
1034 description:
@@ -1387,6 +1403,14 @@ packages:
1403 url: "https://pub.dev"
1404 source: hosted
1405 version: "0.10.1"
1406 + tuple:
1407 + dependency: transitive
1408 + description:
1409 + name: tuple
1410 + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
1411 + url: "https://pub.dev"
1412 + source: hosted
1413 + version: "2.0.2"
1414 two_dimensional_scrollables:
1415 dependency: transitive
1416 description:
pubspec.yaml
+2 -2
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.3.2+32
19 +version: 1.3.3+35
20
21 environment:
22 sdk: ^3.11.0
@@ -43,7 +43,6 @@ dependencies:
43 url_launcher: ^6.3.1
44 dio: ^5.9.2
45 flutter_secure_storage: ^9.2.3
46 - flutter_markdown_plus: ^1.0.7
46 flutter_tts: ^4.2.5
47 file_picker: ^10.3.10
48 path_provider: ^2.1.5
@@ -67,6 +66,7 @@ dependencies:
66 sherpa_onnx: ^1.13.2
67 archive: ^4.0.2
68 loading_animation_widget: ^1.3.0
69 + gpt_markdown: ^1.1.7
70
71 dev_dependencies:
72 flutter_test:
test/features/chat/tools/tool_registry_test.dart
+24 -9
@@ -1,7 +1,9 @@
1 import 'package:flutter_test/flutter_test.dart';
2 import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/features/chat/data/mcp_server_manager.dart';
4 import 'package:localmind/features/chat/data/models/message.dart';
5 import 'package:localmind/features/chat/data/tools/builtin_tool_provider.dart';
6 +import 'package:localmind/features/chat/data/tools/mcp_tool_provider.dart';
7 import 'package:localmind/features/chat/data/tools/tool_definition.dart';
8 import 'package:localmind/features/chat/data/tools/tool_event.dart';
9 import 'package:localmind/features/chat/data/tools/tool_registry.dart';
@@ -27,27 +29,40 @@ void main() {
29 });
30
31 test('registry resolves built-in tools with allow-list filtering', () async {
30 - final registry = ToolRegistry(
31 - providers: [BuiltInToolProvider()],
32 - );
32 + final registry = ToolRegistry(providers: [BuiltInToolProvider()]);
33 final tools = await registry.listTools(allowedTools: {'calc.add'});
34 expect(tools.map((t) => t.name), ['calc.add']);
35 });
36
37 test('registry executes tool from correct provider', () async {
38 - final registry = ToolRegistry(
39 - providers: [BuiltInToolProvider()],
40 - );
38 + final registry = ToolRegistry(providers: [BuiltInToolProvider()]);
39 final result = await registry.execute('calc.add', {'a': 1, 'b': 2});
40 expect(result.success, true);
41 expect(result.output, '3');
42 });
43
44 test('registry returns failure for unknown tool', () async {
47 - final registry = ToolRegistry(
48 - providers: [BuiltInToolProvider()],
49 - );
45 + final registry = ToolRegistry(providers: [BuiltInToolProvider()]);
46 final result = await registry.execute('unknown.tool', {});
47 expect(result.success, false);
48 });
49 +
50 + test('registry exposes and executes example MCP tools', () async {
51 + final manager = McpServerManager()..addExampleServer();
52 + final registry = ToolRegistry(
53 + providers: [
54 + BuiltInToolProvider(),
55 + McpToolProvider(serverManager: manager),
56 + ],
57 + );
58 +
59 + final tools = await registry.listTools();
60 + expect(tools.any((tool) => tool.name == 'example.echo'), true);
61 +
62 + final result = await registry.execute('example.word_count', {
63 + 'text': 'MCP tools are available',
64 + });
65 + expect(result.success, true);
66 + expect(result.output, '4');
67 + });
68 }