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
}