@tduncan / Cortana / commits / d06c3ad

feat: implement MCP server integration with configuration UI and client management

abdulmominsakib committed Apr 1, 2026 at 12:56 UTC d06c3ad7481f1d7c463002829d9b0bd587b77c44
30 files changed +2151 -90
lib/core/constants/app_constants.dart
+1
@@ -18,6 +18,7 @@ class AppConstants {
18 static const String settingsBox = 'settings';
19
20 static const int lmStudioDefaultPort = 1234;
21 + static const int openAICompatibleDefaultPort = 8080;
22 static const int ollamaDefaultPort = 11434;
23
24 static const String openRouterBaseUrl = 'https://openrouter.ai/api/v1';
lib/core/models/enums.dart
+19 -2
@@ -1,14 +1,16 @@
1 import 'package:flutter/material.dart';
2 import 'package:hive_ce/hive.dart';
3
4 -enum ServerType { lmStudio, ollama, openRouter }
4 +enum ServerType { lmStudio, openAICompatible, ollama, openRouter }
5
6 enum ConnectionStatus { connected, disconnected, checking, error }
7
8 -enum MessageRole { user, assistant, system }
8 +enum MessageRole { user, assistant, system, tool }
9
10 enum MessageStatus { sending, streaming, complete, error }
11
12 +enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
13 +
14 class ServerTypeAdapter extends TypeAdapter<ServerType> {
15 @override
16 final int typeId = 10;
@@ -69,6 +71,21 @@ class MessageStatusAdapter extends TypeAdapter<MessageStatus> {
71 }
72 }
73
74 +class ModelStatusAdapter extends TypeAdapter<ModelStatus> {
75 + @override
76 + final int typeId = 15;
77 +
78 + @override
79 + ModelStatus read(BinaryReader reader) {
80 + return ModelStatus.values[reader.readInt()];
81 + }
82 +
83 + @override
84 + void write(BinaryWriter writer, ModelStatus obj) {
85 + writer.writeInt(obj.index);
86 + }
87 +}
88 +
89 class ThemeModeAdapter extends TypeAdapter<ThemeMode> {
90 @override
91 final int typeId = 14;
lib/core/providers/app_providers.dart
+1
@@ -74,6 +74,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
74 _update(state.copyWith(defaultPersonaId: id));
75 void setHasCompletedOnboarding(bool value) =>
76 _update(state.copyWith(hasCompletedOnboarding: value));
77 + void setMcpEnabled(bool value) => _update(state.copyWith(mcpEnabled: value));
78
79 Future<void> _update(AppSettings updated) async {
80 state = updated;
lib/features/chat/data/chat_service.dart
+373 -16
@@ -4,14 +4,17 @@ import 'package:dio/dio.dart';
4 import 'package:localmind/features/servers/data/models/server.dart';
5 import 'package:localmind/features/chat/data/models/message.dart';
6 import 'package:localmind/features/chat/data/models/chat_parameters.dart';
7 +import 'package:localmind/features/chat/data/models/mcp_integration.dart';
8 import 'package:localmind/core/models/enums.dart';
9
10 abstract class ChatService {
10 - Stream<String> sendMessage({
11 + Stream<ChatResponse> sendMessage({
12 required Server server,
13 required String modelId,
14 required List<Message> messages,
15 required ChatParameters params,
16 + List<McpIntegration>? integrations,
17 + String? previousResponseId,
18 });
19
20 void cancelStream();
@@ -20,6 +23,8 @@ abstract class ChatService {
23 switch (type) {
24 case ServerType.lmStudio:
25 return LMStudioChatService(dio);
26 + case ServerType.openAICompatible:
27 + return OpenAICompatibleChatService(dio);
28 case ServerType.ollama:
29 return OllamaChatService(dio);
30 case ServerType.openRouter:
@@ -28,6 +33,82 @@ abstract class ChatService {
33 }
34 }
35
36 +class ChatResponse {
37 + final ChatResponseType type;
38 + final String? content;
39 + final String? reasoningContent;
40 + final ToolCallData? toolCall;
41 + final InvalidToolCallData? invalidToolCall;
42 + final ChatStats? stats;
43 +
44 + const ChatResponse({
45 + required this.type,
46 + this.content,
47 + this.reasoningContent,
48 + this.toolCall,
49 + this.invalidToolCall,
50 + this.stats,
51 + });
52 +}
53 +
54 +enum ChatResponseType { message, reasoning, toolCall, invalidToolCall, done }
55 +
56 +class ToolCallData {
57 + final String tool;
58 + final Map<String, dynamic> arguments;
59 + final String? output;
60 + final ToolProviderInfo? providerInfo;
61 +
62 + const ToolCallData({
63 + required this.tool,
64 + required this.arguments,
65 + this.output,
66 + this.providerInfo,
67 + });
68 +}
69 +
70 +class ToolProviderInfo {
71 + final String type;
72 + final String? pluginId;
73 + final String? serverLabel;
74 +
75 + const ToolProviderInfo({required this.type, this.pluginId, this.serverLabel});
76 +}
77 +
78 +class InvalidToolCallData {
79 + final String reason;
80 + final String? metadataType;
81 + final String? toolName;
82 + final Map<String, dynamic>? arguments;
83 + final ToolProviderInfo? providerInfo;
84 +
85 + const InvalidToolCallData({
86 + required this.reason,
87 + this.metadataType,
88 + this.toolName,
89 + this.arguments,
90 + this.providerInfo,
91 + });
92 +}
93 +
94 +class ChatStats {
95 + final int inputTokens;
96 + final int totalOutputTokens;
97 + final int? reasoningOutputTokens;
98 + final double? tokensPerSecond;
99 + final double? timeToFirstTokenSeconds;
100 + final double? modelLoadTimeSeconds;
101 +
102 + const ChatStats({
103 + required this.inputTokens,
104 + required this.totalOutputTokens,
105 + this.reasoningOutputTokens,
106 + this.tokensPerSecond,
107 + this.timeToFirstTokenSeconds,
108 + this.modelLoadTimeSeconds,
109 + });
110 +}
111 +
112 class LMStudioChatService implements ChatService {
113 final Dio _dio;
114 CancelToken? _cancelToken;
@@ -35,11 +116,275 @@ class LMStudioChatService implements ChatService {
116 LMStudioChatService(this._dio);
117
118 @override
38 - Stream<String> sendMessage({
119 + Stream<ChatResponse> sendMessage({
120 required Server server,
121 required String modelId,
122 required List<Message> messages,
123 required ChatParameters params,
124 + List<McpIntegration>? integrations,
125 + String? previousResponseId,
126 + }) async* {
127 + _cancelToken = CancelToken();
128 +
129 + final body = <String, dynamic>{
130 + 'model': modelId,
131 + 'input': _formatInput(messages),
132 + 'temperature': params.temperature,
133 + 'top_p': params.topP,
134 + 'max_output_tokens': params.maxTokens,
135 + 'context_length': params.contextLength,
136 + 'stream': true,
137 + 'store': true,
138 + };
139 +
140 + if (params.systemPrompt != null && params.systemPrompt!.isNotEmpty) {
141 + body['system_prompt'] = params.systemPrompt;
142 + }
143 + if (params.topK != null) body['top_k'] = params.topK;
144 + if (params.minP != null) body['min_p'] = params.minP;
145 + if (params.repeatPenalty != null)
146 + body['repeat_penalty'] = params.repeatPenalty;
147 + if (params.reasoningLevel != null)
148 + body['reasoning'] = params.reasoningLevel;
149 + if (integrations != null && integrations.isNotEmpty) {
150 + body['integrations'] = integrations.map((i) => i.toJson()).toList();
151 + }
152 + if (previousResponseId != null)
153 + body['previous_response_id'] = previousResponseId;
154 +
155 + final response = await _dio.post<ResponseBody>(
156 + server.chatEndpoint,
157 + data: body,
158 + options: Options(
159 + responseType: ResponseType.stream,
160 + headers: {
161 + 'Content-Type': 'application/json',
162 + if (server.apiKey != null) 'Authorization': 'Bearer ${server.apiKey}',
163 + },
164 + ),
165 + cancelToken: _cancelToken,
166 + );
167 +
168 + final stream = response.data!.stream;
169 + String buffer = '';
170 + String currentEventType = '';
171 +
172 + await for (final chunk in stream) {
173 + buffer += String.fromCharCodes(chunk);
174 + final lines = buffer.split('\n');
175 + buffer = lines.removeLast();
176 +
177 + for (final line in lines) {
178 + final trimmedLine = line.trim();
179 + if (trimmedLine.isEmpty) continue;
180 +
181 + if (trimmedLine.startsWith('event: ')) {
182 + currentEventType = trimmedLine.substring(7);
183 + } else if (trimmedLine.startsWith('data: ')) {
184 + final data = trimmedLine.substring(6);
185 + if (data.isEmpty) continue;
186 +
187 + try {
188 + final json = jsonDecode(data) as Map<String, dynamic>;
189 + await for (final response in _handleSseEvent(
190 + currentEventType,
191 + json,
192 + )) {
193 + yield response;
194 + }
195 + currentEventType = '';
196 + } catch (e) {
197 + currentEventType = '';
198 + }
199 + }
200 + }
201 + }
202 + }
203 +
204 + Stream<ChatResponse> _handleSseEvent(
205 + String eventType,
206 + Map<String, dynamic> json,
207 + ) async* {
208 + switch (eventType) {
209 + case 'message.delta':
210 + final content = json['content'] as String?;
211 + if (content != null && content.isNotEmpty) {
212 + yield ChatResponse(type: ChatResponseType.message, content: content);
213 + }
214 + break;
215 +
216 + case 'reasoning.delta':
217 + final content = json['content'] as String?;
218 + if (content != null && content.isNotEmpty) {
219 + yield ChatResponse(
220 + type: ChatResponseType.reasoning,
221 + reasoningContent: content,
222 + );
223 + }
224 + break;
225 +
226 + case 'tool_call.start':
227 + final tool = json['tool'] as String?;
228 + final providerInfo = json['provider_info'] as Map<String, dynamic>?;
229 + if (tool != null) {
230 + yield ChatResponse(
231 + type: ChatResponseType.toolCall,
232 + toolCall: ToolCallData(
233 + tool: tool,
234 + arguments: {},
235 + providerInfo: providerInfo != null
236 + ? ToolProviderInfo(
237 + type: providerInfo['type'] as String? ?? '',
238 + pluginId: providerInfo['plugin_id'] as String?,
239 + serverLabel: providerInfo['server_label'] as String?,
240 + )
241 + : null,
242 + ),
243 + );
244 + }
245 + break;
246 +
247 + case 'tool_call.arguments':
248 + final tool = json['tool'] as String?;
249 + final arguments = json['arguments'] as Map<String, dynamic>?;
250 + final providerInfo = json['provider_info'] as Map<String, dynamic>?;
251 + if (tool != null && arguments != null) {
252 + yield ChatResponse(
253 + type: ChatResponseType.toolCall,
254 + toolCall: ToolCallData(
255 + tool: tool,
256 + arguments: arguments,
257 + providerInfo: providerInfo != null
258 + ? ToolProviderInfo(
259 + type: providerInfo['type'] as String? ?? '',
260 + pluginId: providerInfo['plugin_id'] as String?,
261 + serverLabel: providerInfo['server_label'] as String?,
262 + )
263 + : null,
264 + ),
265 + );
266 + }
267 + break;
268 +
269 + case 'tool_call.success':
270 + final tool = json['tool'] as String?;
271 + final arguments = json['arguments'] as Map<String, dynamic>?;
272 + final output = json['output'] as String?;
273 + final providerInfo = json['provider_info'] as Map<String, dynamic>?;
274 + if (tool != null) {
275 + yield ChatResponse(
276 + type: ChatResponseType.toolCall,
277 + toolCall: ToolCallData(
278 + tool: tool,
279 + arguments: arguments ?? {},
280 + output: output,
281 + providerInfo: providerInfo != null
282 + ? ToolProviderInfo(
283 + type: providerInfo['type'] as String? ?? '',
284 + pluginId: providerInfo['plugin_id'] as String?,
285 + serverLabel: providerInfo['server_label'] as String?,
286 + )
287 + : null,
288 + ),
289 + );
290 + }
291 + break;
292 +
293 + case 'tool_call.failure':
294 + final reason = json['reason'] as String?;
295 + final metadata = json['metadata'] as Map<String, dynamic>?;
296 + if (reason != null) {
297 + final providerInfo =
298 + metadata?['provider_info'] as Map<String, dynamic>?;
299 + yield ChatResponse(
300 + type: ChatResponseType.invalidToolCall,
301 + invalidToolCall: InvalidToolCallData(
302 + reason: reason,
303 + metadataType: metadata?['type'] as String?,
304 + toolName: metadata?['tool_name'] as String?,
305 + arguments: metadata?['arguments'] as Map<String, dynamic>?,
306 + providerInfo: providerInfo != null
307 + ? ToolProviderInfo(
308 + type: providerInfo['type'] as String? ?? '',
309 + pluginId: providerInfo['plugin_id'] as String?,
310 + serverLabel: providerInfo['server_label'] as String?,
311 + )
312 + : null,
313 + ),
314 + );
315 + }
316 + break;
317 +
318 + case 'chat.end':
319 + final result = json['result'] as Map<String, dynamic>?;
320 + if (result != null) {
321 + final stats = result['stats'] as Map<String, dynamic>?;
322 + yield ChatResponse(
323 + type: ChatResponseType.done,
324 + stats: stats != null
325 + ? ChatStats(
326 + inputTokens: stats['input_tokens'] as int? ?? 0,
327 + totalOutputTokens:
328 + stats['total_output_tokens'] as int? ?? 0,
329 + reasoningOutputTokens:
330 + stats['reasoning_output_tokens'] as int?,
331 + tokensPerSecond: (stats['tokens_per_second'] as num?)
332 + ?.toDouble(),
333 + timeToFirstTokenSeconds:
334 + (stats['time_to_first_token_seconds'] as num?)
335 + ?.toDouble(),
336 + modelLoadTimeSeconds:
337 + (stats['model_load_time_seconds'] as num?)?.toDouble(),
338 + )
339 + : null,
340 + );
341 + }
342 + break;
343 +
344 + case 'error':
345 + final error = json['error'] as Map<String, dynamic>?;
346 + if (error != null) {
347 + yield ChatResponse(
348 + type: ChatResponseType.message,
349 + content: 'Error: ${error['message'] ?? 'Unknown error'}',
350 + );
351 + }
352 + break;
353 + }
354 + }
355 +
356 + dynamic _formatInput(List<Message> messages) {
357 + final formattedInputs = <Map<String, dynamic>>[];
358 + for (final m in messages) {
359 + if (m.role == MessageRole.system) {
360 + formattedInputs.add({'type': 'system_prompt', 'content': m.content});
361 + } else {
362 + formattedInputs.add({'type': 'text', 'content': m.content});
363 + }
364 + }
365 + return formattedInputs;
366 + }
367 +
368 + @override
369 + void cancelStream() {
370 + _cancelToken?.cancel('User cancelled');
371 + }
372 +}
373 +
374 +class OpenAICompatibleChatService implements ChatService {
375 + final Dio _dio;
376 + CancelToken? _cancelToken;
377 +
378 + OpenAICompatibleChatService(this._dio);
379 +
380 + @override
381 + Stream<ChatResponse> sendMessage({
382 + required Server server,
383 + required String modelId,
384 + required List<Message> messages,
385 + required ChatParameters params,
386 + List<McpIntegration>? integrations,
387 + String? previousResponseId,
388 }) async* {
389 _cancelToken = CancelToken();
390
@@ -79,17 +424,19 @@ class LMStudioChatService implements ChatService {
424 if (line.startsWith('data: ')) {
425 final data = line.substring(6);
426 if (data == '[DONE]') {
427 + yield const ChatResponse(type: ChatResponseType.done);
428 return;
429 }
430 try {
431 final json = jsonDecode(data) as Map<String, dynamic>;
432 final content = json['choices']?[0]?['delta']?['content'];
433 if (content != null && content is String) {
88 - yield content;
434 + yield ChatResponse(
435 + type: ChatResponseType.message,
436 + content: content,
437 + );
438 }
90 - } catch (e) {
91 - // Skip malformed JSON
92 - }
439 + } catch (e) {}
440 }
441 }
442 }
@@ -108,11 +455,13 @@ class OllamaChatService implements ChatService {
455 OllamaChatService(this._dio);
456
457 @override
111 - Stream<String> sendMessage({
458 + Stream<ChatResponse> sendMessage({
459 required Server server,
460 required String modelId,
461 required List<Message> messages,
462 required ChatParameters params,
463 + List<McpIntegration>? integrations,
464 + String? previousResponseId,
465 }) async* {
466 _cancelToken = CancelToken();
467
@@ -150,14 +499,16 @@ class OllamaChatService implements ChatService {
499 final json = jsonDecode(line) as Map<String, dynamic>;
500 final content = json['message']?['content'];
501 if (content != null && content is String) {
153 - yield content;
502 + yield ChatResponse(
503 + type: ChatResponseType.message,
504 + content: content,
505 + );
506 }
507 if (json['done'] == true) {
508 + yield const ChatResponse(type: ChatResponseType.done);
509 return;
510 }
158 - } catch (e) {
159 - // Skip malformed JSON
160 - }
511 + } catch (e) {}
512 }
513 }
514 }
@@ -176,11 +527,13 @@ class OpenRouterChatService implements ChatService {
527 OpenRouterChatService(this._dio);
528
529 @override
179 - Stream<String> sendMessage({
530 + Stream<ChatResponse> sendMessage({
531 required Server server,
532 required String modelId,
533 required List<Message> messages,
534 required ChatParameters params,
535 + List<McpIntegration>? integrations,
536 + String? previousResponseId,
537 }) async* {
538 _cancelToken = CancelToken();
539
@@ -222,17 +575,19 @@ class OpenRouterChatService implements ChatService {
575 if (line.startsWith('data: ')) {
576 final data = line.substring(6);
577 if (data == '[DONE]') {
578 + yield const ChatResponse(type: ChatResponseType.done);
579 return;
580 }
581 try {
582 final json = jsonDecode(data) as Map<String, dynamic>;
583 final content = json['choices']?[0]?['delta']?['content'];
584 if (content != null && content is String) {
231 - yield content;
585 + yield ChatResponse(
586 + type: ChatResponseType.message,
587 + content: content,
588 + );
589 }
233 - } catch (e) {
234 - // Skip malformed JSON
235 - }
590 + } catch (e) {}
591 }
592 }
593 }
@@ -252,5 +607,7 @@ String _roleToString(MessageRole role) {
607 return 'assistant';
608 case MessageRole.system:
609 return 'system';
610 + case MessageRole.tool:
611 + return 'tool';
612 }
613 }
lib/features/chat/data/export_service.dart
+3 -3
@@ -26,13 +26,12 @@ class ExportService {
26 switch (message.role) {
27 case MessageRole.user:
28 buffer.writeln('## 👤 User');
29 - break;
29 case MessageRole.assistant:
30 buffer.writeln('## 🤖 Assistant');
32 - break;
31 case MessageRole.system:
32 buffer.writeln('## ⚙️ System');
35 - break;
33 + case MessageRole.tool:
34 + buffer.writeln('## 🔧 Tool');
35 }
36 buffer.writeln();
37 buffer.writeln(message.content);
@@ -73,6 +72,7 @@ class ExportService {
72 MessageRole.user => 'USER',
73 MessageRole.assistant => 'ASSISTANT',
74 MessageRole.system => 'SYSTEM',
75 + MessageRole.tool => 'TOOL',
76 };
77 buffer.writeln('[$roleLabel]');
78 buffer.writeln(message.content);
lib/features/chat/data/mcp_client.dart new
+242
@@ -0,0 +1,242 @@
1 +import 'dart:async';
2 +import 'package:dio/dio.dart';
3 +
4 +class McpClient {
5 + final String serverUrl;
6 + final Map<String, String>? headers;
7 + final Dio _dio;
8 + bool _initialized = false;
9 + McpCapabilities? _capabilities;
10 +
11 + McpClient({required this.serverUrl, this.headers, Dio? dio})
12 + : _dio = dio ?? Dio();
13 +
14 + bool get isInitialized => _initialized;
15 + McpCapabilities? get capabilities => _capabilities;
16 +
17 + Future<McpCapabilities> initialize() async {
18 + if (_initialized && _capabilities != null) {
19 + return _capabilities!;
20 + }
21 +
22 + try {
23 + final response = await _dio.post(
24 + serverUrl,
25 + data: {
26 + 'jsonrpc': '2.0',
27 + 'id': 1,
28 + 'method': 'initialize',
29 + 'params': {
30 + 'protocolVersion': '2024-11-05',
31 + 'capabilities': {},
32 + 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
33 + },
34 + },
35 + options: Options(
36 + headers: {'Content-Type': 'application/json', ...?headers},
37 + ),
38 + );
39 +
40 + _capabilities = McpCapabilities.fromJson(
41 + response.data['result']['capabilities'] as Map<String, dynamic>,
42 + );
43 + _initialized = true;
44 +
45 + await _sendInitializedNotification();
46 +
47 + return _capabilities!;
48 + } catch (e) {
49 + throw McpException('Failed to initialize MCP client: $e');
50 + }
51 + }
52 +
53 + Future<void> _sendInitializedNotification() async {
54 + try {
55 + await _dio.post(
56 + serverUrl,
57 + data: {
58 + 'jsonrpc': '2.0',
59 + 'method': 'notifications/initialized',
60 + 'params': {},
61 + },
62 + options: Options(headers: {'Content-Type': 'application/json'}),
63 + );
64 + } catch (_) {}
65 + }
66 +
67 + Future<List<McpTool>> listTools() async {
68 + if (!_initialized) {
69 + await initialize();
70 + }
71 +
72 + try {
73 + final response = await _dio.post(
74 + serverUrl,
75 + data: {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list', 'params': {}},
76 + );
77 +
78 + final tools = response.data['result']['tools'] as List?;
79 + if (tools == null) return [];
80 +
81 + return tools
82 + .map((t) => McpTool.fromJson(t as Map<String, dynamic>))
83 + .toList();
84 + } catch (e) {
85 + throw McpException('Failed to list tools: $e');
86 + }
87 + }
88 +
89 + Future<String> callTool(String name, Map<String, dynamic> arguments) async {
90 + if (!_initialized) {
91 + await initialize();
92 + }
93 +
94 + try {
95 + final response = await _dio.post(
96 + serverUrl,
97 + data: {
98 + 'jsonrpc': '2.0',
99 + 'id': 3,
100 + 'method': 'tools/call',
101 + 'params': {'name': name, 'arguments': arguments},
102 + },
103 + );
104 +
105 + final result = response.data['result'];
106 + if (result == null) {
107 + throw McpException('Tool call returned no result');
108 + }
109 +
110 + final content = result['content'] as List?;
111 + if (content == null || content.isEmpty) {
112 + return '';
113 + }
114 +
115 + return content.first['text'] as String? ?? '';
116 + } catch (e) {
117 + if (e is McpException) rethrow;
118 + throw McpException('Failed to call tool $name: $e');
119 + }
120 + }
121 +
122 + Future<List<McpResource>> listResources() async {
123 + if (!_initialized) {
124 + await initialize();
125 + }
126 +
127 + try {
128 + final response = await _dio.post(
129 + serverUrl,
130 + data: {
131 + 'jsonrpc': '2.0',
132 + 'id': 4,
133 + 'method': 'resources/list',
134 + 'params': {},
135 + },
136 + );
137 +
138 + final resources = response.data['result']['resources'] as List?;
139 + if (resources == null) return [];
140 +
141 + return resources
142 + .map((r) => McpResource.fromJson(r as Map<String, dynamic>))
143 + .toList();
144 + } catch (e) {
145 + throw McpException('Failed to list resources: $e');
146 + }
147 + }
148 +
149 + Future<String> readResource(String uri) async {
150 + if (!_initialized) {
151 + await initialize();
152 + }
153 +
154 + try {
155 + final response = await _dio.post(
156 + serverUrl,
157 + data: {
158 + 'jsonrpc': '2.0',
159 + 'id': 5,
160 + 'method': 'resources/read',
161 + 'params': {'uri': uri},
162 + },
163 + );
164 +
165 + final contents = response.data['result']['contents'] as List?;
166 + if (contents == null || contents.isEmpty) {
167 + return '';
168 + }
169 +
170 + return contents.first['text'] as String? ?? '';
171 + } catch (e) {
172 + throw McpException('Failed to read resource $uri: $e');
173 + }
174 + }
175 +
176 + Future<void> close() async {
177 + _initialized = false;
178 + _capabilities = null;
179 + }
180 +}
181 +
182 +class McpCapabilities {
183 + final bool tools;
184 + final bool resources;
185 + final bool prompts;
186 +
187 + McpCapabilities({
188 + this.tools = false,
189 + this.resources = false,
190 + this.prompts = false,
191 + });
192 +
193 + factory McpCapabilities.fromJson(Map<String, dynamic> json) {
194 + return McpCapabilities(
195 + tools: json['tools'] == true,
196 + resources: json['resources'] == true,
197 + prompts: json['prompts'] == true,
198 + );
199 + }
200 +}
201 +
202 +class McpTool {
203 + final String name;
204 + final String? description;
205 + final Map<String, dynamic> inputSchema;
206 +
207 + McpTool({required this.name, this.description, this.inputSchema = const {}});
208 +
209 + factory McpTool.fromJson(Map<String, dynamic> json) {
210 + return McpTool(
211 + name: json['name'] as String,
212 + description: json['description'] as String?,
213 + inputSchema: json['inputSchema'] as Map<String, dynamic>? ?? {},
214 + );
215 + }
216 +}
217 +
218 +class McpResource {
219 + final String uri;
220 + final String? name;
221 + final String? description;
222 + final String? mimeType;
223 +
224 + McpResource({required this.uri, this.name, this.description, this.mimeType});
225 +
226 + factory McpResource.fromJson(Map<String, dynamic> json) {
227 + return McpResource(
228 + uri: json['uri'] as String,
229 + name: json['name'] as String?,
230 + description: json['description'] as String?,
231 + mimeType: json['mimeType'] as String?,
232 + );
233 + }
234 +}
235 +
236 +class McpException implements Exception {
237 + final String message;
238 + McpException(this.message);
239 +
240 + @override
241 + String toString() => 'McpException: $message';
242 +}
lib/features/chat/data/mcp_server_manager.dart new
+90
@@ -0,0 +1,90 @@
1 +import 'package:localmind/features/chat/data/mcp_client.dart';
2 +
3 +class McpServerManager {
4 + final Map<String, McpClient> _clients = {};
5 + final Map<String, McpCapabilities> _capabilities = {};
6 + final Map<String, List<McpTool>> _tools = {};
7 + final Map<String, String> _serverUrls = {};
8 +
9 + Future<void> addServer(
10 + String label,
11 + String url, {
12 + Map<String, String>? headers,
13 + }) async {
14 + if (_clients.containsKey(label)) {
15 + removeServer(label);
16 + }
17 +
18 + final client = McpClient(serverUrl: url, headers: headers);
19 +
20 + final capabilities = await client.initialize();
21 + final tools = await client.listTools();
22 +
23 + _clients[label] = client;
24 + _capabilities[label] = capabilities;
25 + _tools[label] = tools;
26 + _serverUrls[label] = url;
27 + }
28 +
29 + void removeServer(String label) {
30 + _clients[label]?.close();
31 + _clients.remove(label);
32 + _capabilities.remove(label);
33 + _tools.remove(label);
34 + _serverUrls.remove(label);
35 + }
36 +
37 + bool hasServer(String label) => _clients.containsKey(label);
38 +
39 + List<McpTool> getTools(String label) => _tools[label] ?? [];
40 +
41 + Map<String, List<McpTool>> get allTools => Map.unmodifiable(_tools);
42 +
43 + McpCapabilities? getCapabilities(String label) => _capabilities[label];
44 +
45 + String? getServerUrl(String label) => _serverUrls[label];
46 +
47 + Future<String> callTool(
48 + String serverLabel,
49 + String toolName,
50 + Map<String, dynamic> args,
51 + ) async {
52 + final client = _clients[serverLabel];
53 + if (client == null) {
54 + throw McpException('MCP server not connected: $serverLabel');
55 + }
56 +
57 + if (!client.isInitialized) {
58 + await client.initialize();
59 + }
60 +
61 + return client.callTool(toolName, args);
62 + }
63 +
64 + Future<String> readResource(String serverLabel, String uri) async {
65 + final client = _clients[serverLabel];
66 + if (client == null) {
67 + throw McpException('MCP server not connected: $serverLabel');
68 + }
69 +
70 + if (!client.isInitialized) {
71 + await client.initialize();
72 + }
73 +
74 + return client.readResource(uri);
75 + }
76 +
77 + void clear() {
78 + for (final client in _clients.values) {
79 + client.close();
80 + }
81 + _clients.clear();
82 + _capabilities.clear();
83 + _tools.clear();
84 + _serverUrls.clear();
85 + }
86 +
87 + int get serverCount => _clients.length;
88 +
89 + List<String> get serverLabels => _clients.keys.toList();
90 +}
lib/features/chat/data/models/chat_parameters.dart
+20
@@ -5,12 +5,22 @@ class ChatParameters {
5 final double topP;
6 final int maxTokens;
7 final int contextLength;
8 + final String? systemPrompt;
9 + final int? topK;
10 + final double? minP;
11 + final double? repeatPenalty;
12 + final String? reasoningLevel;
13
14 const ChatParameters({
15 required this.temperature,
16 required this.topP,
17 required this.maxTokens,
18 required this.contextLength,
19 + this.systemPrompt,
20 + this.topK,
21 + this.minP,
22 + this.repeatPenalty,
23 + this.reasoningLevel,
24 });
25
26 factory ChatParameters.defaults() => const ChatParameters(
@@ -25,12 +35,22 @@ class ChatParameters {
35 double? topP,
36 int? maxTokens,
37 int? contextLength,
38 + String? systemPrompt,
39 + int? topK,
40 + double? minP,
41 + double? repeatPenalty,
42 + String? reasoningLevel,
43 }) {
44 return ChatParameters(
45 temperature: temperature ?? this.temperature,
46 topP: topP ?? this.topP,
47 maxTokens: maxTokens ?? this.maxTokens,
48 contextLength: contextLength ?? this.contextLength,
49 + systemPrompt: systemPrompt ?? this.systemPrompt,
50 + topK: topK ?? this.topK,
51 + minP: minP ?? this.minP,
52 + repeatPenalty: repeatPenalty ?? this.repeatPenalty,
53 + reasoningLevel: reasoningLevel ?? this.reasoningLevel,
54 );
55 }
56 }
lib/features/chat/data/models/mcp_integration.dart new
+120
@@ -0,0 +1,120 @@
1 +enum McpIntegrationType { ephemeralMcp, plugin }
2 +
3 +class McpIntegration {
4 + final McpIntegrationType type;
5 + final String? serverLabel;
6 + final String? serverUrl;
7 + final String? pluginId;
8 + final List<String>? allowedTools;
9 + final Map<String, String>? headers;
10 +
11 + const McpIntegration({
12 + required this.type,
13 + this.serverLabel,
14 + this.serverUrl,
15 + this.pluginId,
16 + this.allowedTools,
17 + this.headers,
18 + });
19 +
20 + Map<String, dynamic> toJson() {
21 + if (type == McpIntegrationType.plugin) {
22 + return {
23 + 'type': 'plugin',
24 + 'id': pluginId,
25 + if (allowedTools != null) 'allowed_tools': allowedTools,
26 + };
27 + }
28 + return {
29 + 'type': 'ephemeral_mcp',
30 + 'server_label': serverLabel,
31 + 'server_url': serverUrl,
32 + if (allowedTools != null) 'allowed_tools': allowedTools,
33 + if (headers != null) 'headers': headers,
34 + };
35 + }
36 +
37 + factory McpIntegration.fromJson(Map<String, dynamic> json) {
38 + final typeStr = json['type'] as String;
39 + final type = typeStr == 'plugin'
40 + ? McpIntegrationType.plugin
41 + : McpIntegrationType.ephemeralMcp;
42 +
43 + return McpIntegration(
44 + type: type,
45 + serverLabel: json['server_label'] as String?,
46 + serverUrl: json['server_url'] as String?,
47 + pluginId: json['id'] as String?,
48 + allowedTools: (json['allowed_tools'] as List?)?.cast<String>(),
49 + headers: (json['headers'] as Map?)?.cast<String, String>(),
50 + );
51 + }
52 +
53 + McpIntegration copyWith({
54 + McpIntegrationType? type,
55 + String? serverLabel,
56 + String? serverUrl,
57 + String? pluginId,
58 + List<String>? allowedTools,
59 + Map<String, String>? headers,
60 + }) {
61 + return McpIntegration(
62 + type: type ?? this.type,
63 + serverLabel: serverLabel ?? this.serverLabel,
64 + serverUrl: serverUrl ?? this.serverUrl,
65 + pluginId: pluginId ?? this.pluginId,
66 + allowedTools: allowedTools ?? this.allowedTools,
67 + headers: headers ?? this.headers,
68 + );
69 + }
70 +}
71 +
72 +class ChatMcpConfig {
73 + final List<McpIntegration> integrations;
74 + final bool autoExecuteTools;
75 + final Map<String, String> activeMcpServers;
76 + final bool enabled;
77 +
78 + const ChatMcpConfig({
79 + this.integrations = const [],
80 + this.autoExecuteTools = true,
81 + this.activeMcpServers = const {},
82 + this.enabled = true,
83 + });
84 +
85 + ChatMcpConfig copyWith({
86 + List<McpIntegration>? integrations,
87 + bool? autoExecuteTools,
88 + Map<String, String>? activeMcpServers,
89 + bool? enabled,
90 + }) {
91 + return ChatMcpConfig(
92 + integrations: integrations ?? this.integrations,
93 + autoExecuteTools: autoExecuteTools ?? this.autoExecuteTools,
94 + activeMcpServers: activeMcpServers ?? this.activeMcpServers,
95 + enabled: enabled ?? this.enabled,
96 + );
97 + }
98 +
99 + Map<String, dynamic> toJson() {
100 + return {
101 + 'integrations': integrations.map((i) => i.toJson()).toList(),
102 + 'autoExecuteTools': autoExecuteTools,
103 + 'activeMcpServers': activeMcpServers,
104 + };
105 + }
106 +
107 + factory ChatMcpConfig.fromJson(Map<String, dynamic> json) {
108 + return ChatMcpConfig(
109 + integrations:
110 + (json['integrations'] as List?)
111 + ?.map((i) => McpIntegration.fromJson(i as Map<String, dynamic>))
112 + .toList() ??
113 + [],
114 + autoExecuteTools: json['autoExecuteTools'] as bool? ?? true,
115 + activeMcpServers:
116 + (json['activeMcpServers'] as Map?)?.cast<String, String>() ?? {},
117 + enabled: json['enabled'] as bool? ?? true,
118 + );
119 + }
120 +}
lib/features/chat/data/models/message.dart
+32
@@ -38,6 +38,15 @@ class Message extends HiveObject {
38 @HiveField(10)
39 final int? generationTimeMs;
40
41 + @HiveField(11)
42 + final String? reasoningContent;
43 +
44 + @HiveField(12)
45 + final List<ToolCallData>? toolCalls;
46 +
47 + @HiveField(13)
48 + final String? toolCallId;
49 +
50 Message({
51 required this.id,
52 required this.conversationId,
@@ -50,6 +59,9 @@ class Message extends HiveObject {
59 this.errorMessage,
60 this.attachmentPaths,
61 this.generationTimeMs,
62 + this.reasoningContent,
63 + this.toolCalls,
64 + this.toolCallId,
65 });
66
67 Message copyWith({
@@ -64,6 +76,9 @@ class Message extends HiveObject {
76 String? errorMessage,
77 List<String>? attachmentPaths,
78 int? generationTimeMs,
79 + String? reasoningContent,
80 + List<ToolCallData>? toolCalls,
81 + String? toolCallId,
82 }) {
83 return Message(
84 id: id ?? this.id,
@@ -77,6 +92,23 @@ class Message extends HiveObject {
92 errorMessage: errorMessage ?? this.errorMessage,
93 attachmentPaths: attachmentPaths ?? this.attachmentPaths,
94 generationTimeMs: generationTimeMs ?? this.generationTimeMs,
95 + reasoningContent: reasoningContent ?? this.reasoningContent,
96 + toolCalls: toolCalls ?? this.toolCalls,
97 + toolCallId: toolCallId ?? this.toolCallId,
98 );
99 }
100 }
101 +
102 +class ToolCallData {
103 + final String id;
104 + final String toolName;
105 + final Map<String, dynamic> arguments;
106 + final String? result;
107 +
108 + ToolCallData({
109 + required this.id,
110 + required this.toolName,
111 + required this.arguments,
112 + this.result,
113 + });
114 +}
lib/features/chat/data/models/message.g.dart
+11 -3
@@ -8,7 +8,7 @@ part of 'message.dart';
8
9 class MessageAdapter extends TypeAdapter<Message> {
10 @override
11 - final typeId = 1;
11 + final int typeId = 1;
12
13 @override
14 Message read(BinaryReader reader) {
@@ -30,13 +30,15 @@ class MessageAdapter extends TypeAdapter<Message> {
30 errorMessage: fields[8] as String?,
31 attachmentPaths: (fields[9] as List?)?.cast<String>(),
32 generationTimeMs: (fields[10] as num?)?.toInt(),
33 + reasoningContent: fields[11] as String?,
34 + toolCallId: fields[13] as String?,
35 );
36 }
37
38 @override
39 void write(BinaryWriter writer, Message obj) {
40 writer
39 - ..writeByte(11)
41 + ..writeByte(14)
42 ..writeByte(0)
43 ..write(obj.id)
44 ..writeByte(1)
@@ -58,7 +60,13 @@ class MessageAdapter extends TypeAdapter<Message> {
60 ..writeByte(9)
61 ..write(obj.attachmentPaths)
62 ..writeByte(10)
61 - ..write(obj.generationTimeMs);
63 + ..write(obj.generationTimeMs)
64 + ..writeByte(11)
65 + ..write(obj.reasoningContent)
66 + ..writeByte(12)
67 + ..write(obj.toolCalls)
68 + ..writeByte(13)
69 + ..write(obj.toolCallId);
70 }
71
72 @override
lib/features/chat/providers/chat_providers.dart
+122 -14
@@ -28,6 +28,33 @@ class SelectedModelNotifier extends Notifier<ModelInfo?> {
28 }
29 }
30
31 +final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
32 + final selectedModel = ref.read(selectedModelProvider);
33 + if (selectedModel != null) return;
34 +
35 + final activeServer = ref.read(activeServerProvider);
36 + if (activeServer == null) return;
37 + if (activeServer.type == ServerType.openRouter) return;
38 +
39 + final apiService = ref.read(serverApiServiceProvider);
40 + final loadedModels = await apiService.fetchRunningModels(activeServer);
41 + if (loadedModels.isEmpty) return;
42 +
43 + final availableModels = await ref.read(
44 + availableModelsProvider(activeServer.id).future,
45 + );
46 + if (availableModels.isEmpty) return;
47 +
48 + final typedModels = availableModels.cast<ModelInfo>();
49 + final firstLoadedModel = typedModels
50 + .where((m) => loadedModels.contains(m.id))
51 + .firstOrNull;
52 +
53 + if (firstLoadedModel != null) {
54 + ref.read(selectedModelProvider.notifier).setModel(firstLoadedModel);
55 + }
56 +});
57 +
58 final isStreamingProvider = NotifierProvider<IsStreamingNotifier, bool>(() {
59 return IsStreamingNotifier();
60 });
@@ -41,6 +68,60 @@ class IsStreamingNotifier extends Notifier<bool> {
68 }
69 }
70
71 +class ModelLoadingState {
72 + final bool isLoading;
73 + final String? modelId;
74 + final double? progress;
75 +
76 + const ModelLoadingState({
77 + this.isLoading = false,
78 + this.modelId,
79 + this.progress,
80 + });
81 +
82 + ModelLoadingState copyWith({
83 + bool? isLoading,
84 + String? modelId,
85 + double? progress,
86 + }) {
87 + return ModelLoadingState(
88 + isLoading: isLoading ?? this.isLoading,
89 + modelId: modelId ?? this.modelId,
90 + progress: progress ?? this.progress,
91 + );
92 + }
93 +}
94 +
95 +final modelLoadingProvider =
96 + NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
97 + return ModelLoadingNotifier();
98 + });
99 +
100 +class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
101 + @override
102 + ModelLoadingState build() => const ModelLoadingState();
103 +
104 + void setLoading(String modelId, {double? progress}) {
105 + state = ModelLoadingState(
106 + isLoading: true,
107 + modelId: modelId,
108 + progress: progress,
109 + );
110 + }
111 +
112 + void updateProgress(double progress) {
113 + state = state.copyWith(progress: progress);
114 + }
115 +
116 + void setLoaded() {
117 + state = const ModelLoadingState();
118 + }
119 +}
120 +
121 +final modelThinkingProvider = Provider<bool>((ref) {
122 + return ref.watch(isStreamingProvider);
123 +});
124 +
125 final chatParamsProvider = Provider<ChatParameters>((ref) {
126 final settings = ref.watch(settingsProvider);
127 final activeConv = ref.watch(conv.activeConversationProvider);
@@ -176,7 +257,7 @@ final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
257 });
258
259 class ChatNotifier extends Notifier<ChatState> {
179 - StreamSubscription<String>? _streamSubscription;
260 + StreamSubscription<ChatResponse>? _streamSubscription;
261 String? _currentConversationId;
262
263 @override
@@ -290,6 +371,9 @@ class ChatNotifier extends Notifier<ChatState> {
371
372 try {
373 _streamSubscription?.cancel();
374 +
375 + String reasoningContent = '';
376 +
377 _streamSubscription = chatService
378 .sendMessage(
379 server: server,
@@ -298,20 +382,44 @@ class ChatNotifier extends Notifier<ChatState> {
382 params: chatParams,
383 )
384 .listen(
301 - (chunk) {
302 - final currentContent = state.streamingMessage?.content ?? '';
303 - final updatedMessage =
304 - (state.streamingMessage ?? assistantMessage).copyWith(
305 - content: currentContent + chunk,
385 + (response) {
386 + switch (response.type) {
387 + case ChatResponseType.message:
388 + final currentContent = state.streamingMessage?.content ?? '';
389 + final updatedMessage =
390 + (state.streamingMessage ?? assistantMessage).copyWith(
391 + content: currentContent + (response.content ?? ''),
392 + );
393 + state = state.copyWith(streamingMessage: updatedMessage);
394 + final messageIndex = state.messages.indexWhere(
395 + (m) => m.id == assistantMessageId,
396 );
307 - state = state.copyWith(streamingMessage: updatedMessage);
308 - final messageIndex = state.messages.indexWhere(
309 - (m) => m.id == assistantMessageId,
310 - );
311 - if (messageIndex != -1) {
312 - final updatedMessages = List<Message>.from(state.messages);
313 - updatedMessages[messageIndex] = updatedMessage;
314 - state = state.copyWith(messages: updatedMessages);
397 + if (messageIndex != -1) {
398 + final updatedMessages = List<Message>.from(state.messages);
399 + updatedMessages[messageIndex] = updatedMessage;
400 + state = state.copyWith(messages: updatedMessages);
401 + }
402 + break;
403 + case ChatResponseType.reasoning:
404 + reasoningContent += response.reasoningContent ?? '';
405 + final reasoningMessage =
406 + (state.streamingMessage ?? assistantMessage).copyWith(
407 + reasoningContent: reasoningContent,
408 + );
409 + state = state.copyWith(streamingMessage: reasoningMessage);
410 + final msgIndex = state.messages.indexWhere(
411 + (m) => m.id == assistantMessageId,
412 + );
413 + if (msgIndex != -1) {
414 + final updatedMessages = List<Message>.from(state.messages);
415 + updatedMessages[msgIndex] = reasoningMessage;
416 + state = state.copyWith(messages: updatedMessages);
417 + }
418 + break;
419 + case ChatResponseType.toolCall:
420 + case ChatResponseType.invalidToolCall:
421 + case ChatResponseType.done:
422 + break;
423 }
424 },
425 onDone: () async {
lib/features/chat/screens/chat_screen.dart
+89 -16
@@ -3,12 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:shadcn_ui/shadcn_ui.dart';
5 import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/providers/app_providers.dart';
7 import 'package:localmind/core/providers/storage_providers.dart';
8 import 'package:localmind/core/routes/app_routes.dart';
9 import 'package:localmind/features/chat/data/models/message.dart';
10 import 'package:localmind/features/chat/providers/chat_providers.dart';
11 import 'package:localmind/features/chat/views/components/chat_bubble.dart';
12 import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
13 +import 'package:localmind/features/chat/views/components/mcp_config_sheet.dart';
14 import 'package:localmind/features/conversations/data/models/conversation.dart';
15 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
16 as conv;
@@ -47,6 +49,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
49 }
50
51 void _onScroll() {
52 + final chatState = ref.read(chatProvider);
53 + if (chatState.isStreaming) return;
54 +
55 final isNearBottom =
56 _scrollController.position.pixels >=
57 _scrollController.position.maxScrollExtent - 100;
@@ -65,9 +70,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
70
71 @override
72 Widget build(BuildContext context) {
73 + ref.watch(autoSelectFirstLoadedModelProvider);
74 +
75 final chatState = ref.watch(chatProvider);
76 final selectedModel = ref.watch(selectedModelProvider);
77 final connectionStatus = ref.watch(connectionStatusProvider);
78 + final isStreaming = ref.watch(isStreamingProvider);
79 final activeConversation = ref.watch(conv.activeConversationProvider);
80 final personaId = activeConversation?.personaId;
81 final persona = personaId != null
@@ -77,10 +85,22 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
85 final isDark = theme.brightness == Brightness.dark;
86
87 ref.listen(chatProvider, (previous, next) {
80 - if (previous?.messages.length != next.messages.length) {
88 + final newMessage = previous?.messages.length != next.messages.length;
89 + final newStreaming =
90 + next.isStreaming &&
91 + next.streamingMessage != null &&
92 + next.streamingMessage != previous?.streamingMessage;
93 +
94 + if (newMessage || newStreaming) {
95 WidgetsBinding.instance.addPostFrameCallback((_) {
96 if (_scrollController.hasClients) {
83 - _scrollToBottom();
97 + if (next.isStreaming) {
98 + _scrollController.jumpTo(
99 + _scrollController.position.maxScrollExtent,
100 + );
101 + } else {
102 + _scrollToBottom();
103 + }
104 }
105 });
106 }
@@ -108,6 +128,29 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
128 if (persona != null) ...[
129 Text(persona.emoji, style: const TextStyle(fontSize: 18)),
130 const SizedBox(width: 4),
131 + Text(
132 + persona.name,
133 + style: TextStyle(
134 + fontSize: 14,
135 + color: isDark
136 + ? const Color(0xFF9CA3AF)
137 + : const Color(0xFF6B7280),
138 + ),
139 + ),
140 + const SizedBox(width: 8),
141 + ],
142 + if (isStreaming) ...[
143 + const SizedBox(
144 + width: 14,
145 + height: 14,
146 + child: CircularProgressIndicator(
147 + strokeWidth: 2,
148 + valueColor: AlwaysStoppedAnimation<Color>(
149 + Color(0xFF4CAF50),
150 + ),
151 + ),
152 + ),
153 + const SizedBox(width: 6),
154 ],
155 Text(
156 selectedModel?.displayName ?? 'Select Model',
@@ -125,6 +168,25 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
168 ),
169 ),
170 actions: [
171 + Consumer(
172 + builder: (context, ref, child) {
173 + final settings = ref.watch(settingsProvider);
174 + final mcpConfig = ref.watch(chatMcpConfigProvider);
175 + final isEnabled = settings.mcpEnabled && mcpConfig.enabled;
176 + return IconButton(
177 + icon: Icon(
178 + isEnabled ? Icons.extension : Icons.extension_off_outlined,
179 + color: isEnabled
180 + ? theme.colorScheme.primary
181 + : (isDark
182 + ? const Color(0xFF666666)
183 + : const Color(0xFF999999)),
184 + ),
185 + tooltip: isEnabled ? 'MCP Enabled' : 'MCP Disabled',
186 + onPressed: () => showMcpConfigSheet(context),
187 + );
188 + },
189 + ),
190 IconButton(
191 icon: const Icon(Icons.settings_outlined),
192 onPressed: () => context.push(AppRoutes.settings),
@@ -188,8 +250,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
250 onQuickPrompt: (prompt) =>
251 ref.read(chatProvider.notifier).sendMessage(prompt),
252 quickPrompts: _quickPrompts,
191 - recentConversations:
192 - ref.watch(recentConversationsProvider),
253 + recentConversations: ref.watch(
254 + recentConversationsProvider,
255 + ),
256 onSeeAll: () {
257 if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
258 Scaffold.of(context).openDrawer();
@@ -258,11 +321,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
321 ],
322 ),
323 ),
261 - _SmartReplyChips(
262 - onSend: (message) {
263 - ref.read(chatProvider.notifier).sendMessage(message);
264 - },
265 - ),
324 + if (!chatState.isStreaming)
325 + _SmartReplyChips(
326 + onSend: (message) {
327 + ref.read(chatProvider.notifier).sendMessage(message);
328 + },
329 + ),
330 ChatInputBar(
331 isStreaming: chatState.isStreaming,
332 onSend: (message) {
@@ -519,7 +583,9 @@ class _EmptyState extends StatelessWidget {
583 'Your AI. Your Device. Your Rules.',
584 style: TextStyle(
585 fontSize: 14,
522 - color: isDark ? const Color(0xFF888888) : const Color(0xFF666666),
586 + color: isDark
587 + ? const Color(0xFF888888)
588 + : const Color(0xFF666666),
589 ),
590 ),
591 const SizedBox(height: 32),
@@ -544,7 +610,9 @@ class _EmptyState extends StatelessWidget {
610 ? const Color(0xFF2A2A2A)
611 : const Color(0xFFF5F5F5),
612 side: BorderSide(
547 - color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
613 + color: isDark
614 + ? const Color(0xFF3A3A3A)
615 + : const Color(0xFFE5E5E5),
616 ),
617 );
618 }).toList(),
@@ -559,7 +627,9 @@ class _EmptyState extends StatelessWidget {
627 style: TextStyle(
628 fontSize: 14,
629 fontWeight: FontWeight.w500,
562 - color: isDark ? const Color(0xFFA0A0A0) : const Color(0xFF666666),
630 + color: isDark
631 + ? const Color(0xFFA0A0A0)
632 + : const Color(0xFF666666),
633 ),
634 ),
635 const SizedBox(width: 8),
@@ -570,14 +640,18 @@ class _EmptyState extends StatelessWidget {
640 style: TextStyle(
641 fontSize: 14,
642 fontWeight: FontWeight.w500,
573 - color: isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB),
643 + color: isDark
644 + ? const Color(0xFF3B82F6)
645 + : const Color(0xFF2563EB),
646 ),
647 ),
648 ),
649 ],
650 ),
651 const SizedBox(height: 12),
580 - ...recentConversations.take(5).map(
652 + ...recentConversations
653 + .take(5)
654 + .map(
655 (conv) => Padding(
656 padding: const EdgeInsets.only(bottom: 8),
657 child: _RecentConversationItem(conversation: conv),
@@ -688,7 +762,6 @@ class _MessageList extends StatelessWidget {
762 for (final message in messages) {
763 if (streamingMessage != null &&
764 message.id == streamingMessage!.id &&
691 - message.content.isEmpty &&
765 isStreaming) {
766 continue;
767 }
@@ -697,7 +770,7 @@ class _MessageList extends StatelessWidget {
770
771 return ListView.builder(
772 controller: scrollController,
700 - padding: const EdgeInsets.symmetric(vertical: 16),
773 + padding: EdgeInsets.only(top: 16, bottom: 16),
774 itemCount:
775 allMessages.length +
776 (streamingMessage != null && isStreaming ? 1 : 0),
lib/features/chat/views/components/chat_bubble.dart
+75
@@ -5,6 +5,7 @@ import 'package:localmind/features/chat/data/models/message.dart';
5 import 'package:localmind/features/chat/views/components/code_block.dart';
6 import 'package:localmind/features/chat/views/components/message_action_bar.dart';
7 import 'package:localmind/features/chat/views/components/typing_indicator.dart';
8 +import 'package:localmind/features/chat/views/components/reasoning_widget.dart';
9
10 class ChatBubble extends StatelessWidget {
11 const ChatBubble({
@@ -37,6 +38,8 @@ class ChatBubble extends StatelessWidget {
38 );
39 case MessageRole.system:
40 return _SystemBubble(message: message);
41 + case MessageRole.tool:
42 + return _ToolBubble(message: message);
43 }
44 }
45 }
@@ -139,6 +142,12 @@ class _AssistantBubble extends StatelessWidget {
142 child: Column(
143 crossAxisAlignment: CrossAxisAlignment.start,
144 children: [
145 + if (message.reasoningContent != null &&
146 + message.reasoningContent!.isNotEmpty)
147 + ReasoningWidget(
148 + reasoningContent: message.reasoningContent,
149 + isStreaming: isStreaming,
150 + ),
151 if (isStreaming && message.content.isEmpty)
152 const TypingIndicator()
153 else
@@ -320,6 +329,72 @@ class _SystemBubble extends StatelessWidget {
329 }
330 }
331
332 +class _ToolBubble extends StatelessWidget {
333 + const _ToolBubble({required this.message});
334 +
335 + final Message message;
336 +
337 + @override
338 + Widget build(BuildContext context) {
339 + final theme = Theme.of(context);
340 + final isDark = theme.brightness == Brightness.dark;
341 +
342 + return Align(
343 + alignment: Alignment.centerLeft,
344 + child: Container(
345 + constraints: BoxConstraints(
346 + maxWidth: MediaQuery.of(context).size.width * 0.8,
347 + ),
348 + margin: const EdgeInsets.only(left: 8, right: 48, top: 4, bottom: 4),
349 + padding: const EdgeInsets.all(12),
350 + decoration: BoxDecoration(
351 + color: isDark ? const Color(0xFF1F2937) : const Color(0xFFEEF2FF),
352 + borderRadius: BorderRadius.circular(12),
353 + border: Border.all(
354 + color: isDark ? const Color(0xFF374151) : const Color(0xFFC7D2FE),
355 + ),
356 + ),
357 + child: Column(
358 + crossAxisAlignment: CrossAxisAlignment.start,
359 + children: [
360 + Row(
361 + children: [
362 + Icon(
363 + Icons.build_outlined,
364 + size: 14,
365 + color: isDark
366 + ? const Color(0xFF9CA3AF)
367 + : const Color(0xFF6B7280),
368 + ),
369 + const SizedBox(width: 4),
370 + Text(
371 + 'Tool: ${message.toolCallId ?? "Unknown"}',
372 + style: TextStyle(
373 + fontSize: 12,
374 + fontWeight: FontWeight.w500,
375 + color: isDark
376 + ? const Color(0xFF9CA3AF)
377 + : const Color(0xFF6B7280),
378 + ),
379 + ),
380 + ],
381 + ),
382 + const SizedBox(height: 8),
383 + Text(
384 + message.content,
385 + style: TextStyle(
386 + fontSize: 13,
387 + color: isDark ? Colors.white : Colors.black87,
388 + fontFamily: 'monospace',
389 + ),
390 + ),
391 + ],
392 + ),
393 + ),
394 + );
395 + }
396 +}
397 +
398 String _formatTime(DateTime dateTime) {
399 final hour = dateTime.hour.toString().padLeft(2, '0');
400 final minute = dateTime.minute.toString().padLeft(2, '0');
lib/features/chat/views/components/mcp_config_sheet.dart new
+338
@@ -0,0 +1,338 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/core/providers/app_providers.dart';
4 +import 'package:localmind/features/chat/data/models/mcp_integration.dart';
5 +
6 +class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
7 + @override
8 + ChatMcpConfig build() {
9 + final settings = ref.watch(settingsProvider);
10 + return ChatMcpConfig(enabled: settings.mcpEnabled);
11 + }
12 +
13 + void setConfig(ChatMcpConfig config) {
14 + state = config;
15 + }
16 +
17 + void addIntegration(McpIntegration integration) {
18 + state = state.copyWith(
19 + integrations: [...state.integrations, integration],
20 + activeMcpServers:
21 + integration.serverLabel != null && integration.serverUrl != null
22 + ? {
23 + ...state.activeMcpServers,
24 + integration.serverLabel!: integration.serverUrl!,
25 + }
26 + : state.activeMcpServers,
27 + );
28 + }
29 +
30 + void removeIntegration(int index) {
31 + final integration = state.integrations[index];
32 + final newIntegrations = List<McpIntegration>.from(state.integrations)
33 + ..removeAt(index);
34 + final newServers = Map<String, String>.from(state.activeMcpServers);
35 + if (integration.serverLabel != null) {
36 + newServers.remove(integration.serverLabel);
37 + }
38 + state = state.copyWith(
39 + integrations: newIntegrations,
40 + activeMcpServers: newServers,
41 + );
42 + }
43 +
44 + void toggleAutoExecute() {
45 + state = state.copyWith(autoExecuteTools: !state.autoExecuteTools);
46 + }
47 +
48 + void toggleEnabled() {
49 + state = state.copyWith(enabled: !state.enabled);
50 + }
51 +
52 + void clearAll() {
53 + final settings = ref.read(settingsProvider);
54 + state = ChatMcpConfig(enabled: settings.mcpEnabled);
55 + }
56 +}
57 +
58 +final chatMcpConfigProvider =
59 + NotifierProvider<ChatMcpConfigNotifier, ChatMcpConfig>(() {
60 + return ChatMcpConfigNotifier();
61 + });
62 +
63 +class McpConfigSheet extends ConsumerStatefulWidget {
64 + const McpConfigSheet({super.key});
65 +
66 + @override
67 + ConsumerState<McpConfigSheet> createState() => _McpConfigSheetState();
68 +}
69 +
70 +class _McpConfigSheetState extends ConsumerState<McpConfigSheet> {
71 + final _serverLabelController = TextEditingController();
72 + final _serverUrlController = TextEditingController();
73 +
74 + @override
75 + void dispose() {
76 + _serverLabelController.dispose();
77 + _serverUrlController.dispose();
78 + super.dispose();
79 + }
80 +
81 + @override
82 + Widget build(BuildContext context) {
83 + final config = ref.watch(chatMcpConfigProvider);
84 + final settings = ref.watch(settingsProvider);
85 + final theme = Theme.of(context);
86 + final isDark = theme.brightness == Brightness.dark;
87 + final isGloballyEnabled = settings.mcpEnabled;
88 +
89 + return Container(
90 + padding: const EdgeInsets.all(16),
91 + decoration: BoxDecoration(
92 + color: isDark ? const Color(0xFF1F1F1F) : Colors.white,
93 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
94 + ),
95 + child: Column(
96 + crossAxisAlignment: CrossAxisAlignment.start,
97 + mainAxisSize: MainAxisSize.min,
98 + children: [
99 + Center(
100 + child: Container(
101 + width: 40,
102 + height: 4,
103 + decoration: BoxDecoration(
104 + color: isDark
105 + ? const Color(0xFF3A3A3A)
106 + : const Color(0xFFE5E5E5),
107 + borderRadius: BorderRadius.circular(2),
108 + ),
109 + ),
110 + ),
111 + const SizedBox(height: 16),
112 + Row(
113 + children: [
114 + Icon(
115 + Icons.extension,
116 + color: isGloballyEnabled
117 + ? theme.colorScheme.primary
118 + : (isDark
119 + ? const Color(0xFF666666)
120 + : const Color(0xFF999999)),
121 + ),
122 + const SizedBox(width: 8),
123 + Text(
124 + 'MCP Servers',
125 + style: TextStyle(
126 + fontSize: 18,
127 + fontWeight: FontWeight.bold,
128 + color: isDark ? Colors.white : Colors.black,
129 + ),
130 + ),
131 + ],
132 + ),
133 + if (!isGloballyEnabled) ...[
134 + const SizedBox(height: 12),
135 + Container(
136 + padding: const EdgeInsets.all(12),
137 + decoration: BoxDecoration(
138 + color: isDark
139 + ? Colors.orange.withValues(alpha: 0.1)
140 + : Colors.orange.withValues(alpha: 0.1),
141 + borderRadius: BorderRadius.circular(8),
142 + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
143 + ),
144 + child: Row(
145 + children: [
146 + const Icon(
147 + Icons.info_outline,
148 + color: Colors.orange,
149 + size: 20,
150 + ),
151 + const SizedBox(width: 8),
152 + Expanded(
153 + child: Text(
154 + 'MCP is disabled. Enable it in Settings to use MCP servers.',
155 + style: TextStyle(
156 + fontSize: 13,
157 + color: isDark ? Colors.orange[200] : Colors.orange[800],
158 + ),
159 + ),
160 + ),
161 + ],
162 + ),
163 + ),
164 + ],
165 + const SizedBox(height: 16),
166 + SwitchListTile(
167 + title: Text(
168 + 'Enable MCP for this chat',
169 + style: TextStyle(color: isDark ? Colors.white : Colors.black),
170 + ),
171 + subtitle: Text(
172 + isGloballyEnabled
173 + ? 'Toggle MCP functionality for this session'
174 + : 'MCP is disabled globally in Settings',
175 + style: TextStyle(
176 + fontSize: 12,
177 + color: isDark
178 + ? const Color(0xFF9CA3AF)
179 + : const Color(0xFF6B7280),
180 + ),
181 + ),
182 + value: config.enabled,
183 + onChanged: isGloballyEnabled
184 + ? (value) {
185 + ref.read(chatMcpConfigProvider.notifier).toggleEnabled();
186 + }
187 + : null,
188 + ),
189 + if (config.enabled && isGloballyEnabled) ...[
190 + const Divider(),
191 + SwitchListTile(
192 + title: Text(
193 + 'Auto-execute tools',
194 + style: TextStyle(color: isDark ? Colors.white : Colors.black),
195 + ),
196 + subtitle: Text(
197 + 'Automatically run MCP tools without asking',
198 + style: TextStyle(
199 + fontSize: 12,
200 + color: isDark
201 + ? const Color(0xFF9CA3AF)
202 + : const Color(0xFF6B7280),
203 + ),
204 + ),
205 + value: config.autoExecuteTools,
206 + onChanged: (value) {
207 + ref.read(chatMcpConfigProvider.notifier).toggleAutoExecute();
208 + },
209 + ),
210 + const Divider(),
211 + Text(
212 + 'Add Ephemeral MCP Server',
213 + style: TextStyle(
214 + fontSize: 14,
215 + fontWeight: FontWeight.w600,
216 + color: isDark ? Colors.white : Colors.black,
217 + ),
218 + ),
219 + const SizedBox(height: 8),
220 + TextField(
221 + controller: _serverLabelController,
222 + decoration: InputDecoration(
223 + labelText: 'Server Label',
224 + hintText: 'e.g., huggingface',
225 + filled: true,
226 + fillColor: isDark
227 + ? const Color(0xFF2A2A2A)
228 + : const Color(0xFFF5F5F5),
229 + ),
230 + style: TextStyle(color: isDark ? Colors.white : Colors.black),
231 + ),
232 + const SizedBox(height: 8),
233 + TextField(
234 + controller: _serverUrlController,
235 + decoration: InputDecoration(
236 + labelText: 'Server URL',
237 + hintText: 'e.g., https://huggingface.co/mcp',
238 + filled: true,
239 + fillColor: isDark
240 + ? const Color(0xFF2A2A2A)
241 + : const Color(0xFFF5F5F5),
242 + ),
243 + keyboardType: TextInputType.url,
244 + style: TextStyle(color: isDark ? Colors.white : Colors.black),
245 + ),
246 + const SizedBox(height: 8),
247 + SizedBox(
248 + width: double.infinity,
249 + child: ElevatedButton.icon(
250 + onPressed: _addServer,
251 + icon: const Icon(Icons.add),
252 + label: const Text('Add Server'),
253 + ),
254 + ),
255 + if (config.integrations.isNotEmpty) ...[
256 + const Divider(),
257 + Text(
258 + 'Active Integrations',
259 + style: TextStyle(
260 + fontSize: 14,
261 + fontWeight: FontWeight.w600,
262 + color: isDark ? Colors.white : Colors.black,
263 + ),
264 + ),
265 + const SizedBox(height: 8),
266 + ConstrainedBox(
267 + constraints: const BoxConstraints(maxHeight: 200),
268 + child: ListView.builder(
269 + shrinkWrap: true,
270 + itemCount: config.integrations.length,
271 + itemBuilder: (context, index) {
272 + final integration = config.integrations[index];
273 + return ListTile(
274 + leading: Icon(
275 + Icons.extension,
276 + color: theme.colorScheme.primary,
277 + ),
278 + title: Text(
279 + integration.serverLabel ?? integration.pluginId ?? '',
280 + style: TextStyle(
281 + color: isDark ? Colors.white : Colors.black,
282 + ),
283 + ),
284 + subtitle: Text(
285 + integration.serverUrl ?? integration.pluginId ?? '',
286 + style: TextStyle(
287 + fontSize: 12,
288 + color: isDark
289 + ? const Color(0xFF9CA3AF)
290 + : const Color(0xFF6B7280),
291 + ),
292 + ),
293 + trailing: IconButton(
294 + icon: const Icon(Icons.delete_outline),
295 + onPressed: () {
296 + ref
297 + .read(chatMcpConfigProvider.notifier)
298 + .removeIntegration(index);
299 + },
300 + ),
301 + );
302 + },
303 + ),
304 + ),
305 + ],
306 + ],
307 + const SizedBox(height: 16),
308 + ],
309 + ),
310 + );
311 + }
312 +
313 + void _addServer() {
314 + final label = _serverLabelController.text.trim();
315 + final url = _serverUrlController.text.trim();
316 + if (label.isEmpty || url.isEmpty) return;
317 +
318 + final integration = McpIntegration(
319 + type: McpIntegrationType.ephemeralMcp,
320 + serverLabel: label,
321 + serverUrl: url,
322 + );
323 +
324 + ref.read(chatMcpConfigProvider.notifier).addIntegration(integration);
325 +
326 + _serverLabelController.clear();
327 + _serverUrlController.clear();
328 + }
329 +}
330 +
331 +void showMcpConfigSheet(BuildContext context) {
332 + showModalBottomSheet(
333 + context: context,
334 + isScrollControlled: true,
335 + backgroundColor: Colors.transparent,
336 + builder: (context) => const McpConfigSheet(),
337 + );
338 +}
lib/features/chat/views/components/reasoning_widget.dart new
+133
@@ -0,0 +1,133 @@
1 +import 'package:flutter/material.dart';
2 +
3 +class ReasoningWidget extends StatefulWidget {
4 + final String? reasoningContent;
5 + final bool isStreaming;
6 +
7 + const ReasoningWidget({
8 + super.key,
9 + this.reasoningContent,
10 + this.isStreaming = false,
11 + });
12 +
13 + @override
14 + State<ReasoningWidget> createState() => _ReasoningWidgetState();
15 +}
16 +
17 +class _ReasoningWidgetState extends State<ReasoningWidget> {
18 + bool _isExpanded = false;
19 +
20 + String _getLastLines(String content, int maxLines) {
21 + final lines = content
22 + .split('\n')
23 + .where((line) => line.trim().isNotEmpty)
24 + .toList();
25 + if (lines.length <= maxLines) {
26 + return content.trim();
27 + }
28 + final lastLines = lines.reversed.take(maxLines).toList().reversed;
29 + return '... ${lastLines.join('\n')}';
30 + }
31 +
32 + @override
33 + Widget build(BuildContext context) {
34 + final theme = Theme.of(context);
35 + final isDark = theme.brightness == Brightness.dark;
36 + final hasContent = widget.reasoningContent?.isNotEmpty == true;
37 +
38 + if (!hasContent && !widget.isStreaming) {
39 + return const SizedBox.shrink();
40 + }
41 +
42 + final previewText = hasContent
43 + ? _getLastLines(widget.reasoningContent!, 4)
44 + : '';
45 +
46 + return GestureDetector(
47 + onTap: () => setState(() => _isExpanded = !_isExpanded),
48 + child: AnimatedContainer(
49 + duration: const Duration(milliseconds: 200),
50 + margin: const EdgeInsets.only(bottom: 8),
51 + padding: const EdgeInsets.all(12),
52 + decoration: BoxDecoration(
53 + color: isDark
54 + ? const Color(0xFF1F2937).withValues(alpha: 0.5)
55 + : const Color(0xFFEEF2FF).withValues(alpha: 0.5),
56 + borderRadius: BorderRadius.circular(8),
57 + border: Border.all(
58 + color: isDark ? const Color(0xFF374151) : const Color(0xFFE0E7FF),
59 + ),
60 + ),
61 + child: Column(
62 + crossAxisAlignment: CrossAxisAlignment.start,
63 + children: [
64 + Row(
65 + children: [
66 + if (widget.isStreaming)
67 + SizedBox(
68 + width: 12,
69 + height: 12,
70 + child: CircularProgressIndicator(
71 + strokeWidth: 2,
72 + color: theme.colorScheme.primary.withValues(alpha: 0.6),
73 + ),
74 + )
75 + else
76 + Icon(
77 + _isExpanded ? Icons.expand_less : Icons.expand_more,
78 + size: 16,
79 + color: isDark
80 + ? const Color(0xFF9CA3AF)
81 + : const Color(0xFF6B7280),
82 + ),
83 + const SizedBox(width: 8),
84 + Text(
85 + 'Thinking',
86 + style: TextStyle(
87 + fontSize: 12,
88 + fontWeight: FontWeight.w500,
89 + color: isDark
90 + ? const Color(0xFF9CA3AF)
91 + : const Color(0xFF6B7280),
92 + ),
93 + ),
94 + if (widget.isStreaming) ...[
95 + const SizedBox(width: 8),
96 + Text(
97 + _getThinkingIndicator(),
98 + style: TextStyle(
99 + fontSize: 11,
100 + color: isDark
101 + ? const Color(0xFF6B7280)
102 + : const Color(0xFF9CA3AF),
103 + ),
104 + ),
105 + ],
106 + ],
107 + ),
108 + if (hasContent) ...[
109 + const SizedBox(height: 8),
110 + Text(
111 + _isExpanded ? widget.reasoningContent! : previewText,
112 + style: TextStyle(
113 + fontSize: 13,
114 + color: isDark
115 + ? const Color(0xFFD1D5DB)
116 + : const Color(0xFF4B5563),
117 + fontStyle: FontStyle.italic,
118 + height: 1.4,
119 + ),
120 + ),
121 + ],
122 + ],
123 + ),
124 + ),
125 + );
126 + }
127 +
128 + final String _thinkingText = '';
129 + String _getThinkingIndicator() {
130 + if (!widget.isStreaming) return '';
131 + return _thinkingText;
132 + }
133 +}
lib/features/conversations/views/components/new_chat_button.dart
+3
@@ -20,6 +20,9 @@ class NewChatButton extends ConsumerWidget {
20 onPressed: () {
21 ref.read(chatProvider.notifier).startNewConversation();
22 context.go(AppRoutes.home);
23 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
24 + Navigator.pop(context);
25 + }
26 },
27 icon: const Icon(Icons.add, size: 18),
28 label: const Text('New Chat'),
lib/features/models/data/models/model_info.dart
+32
@@ -12,6 +12,7 @@ class ModelInfo {
12 final ServerType serverType;
13 final String serverId;
14 final DateTime? modifiedAt;
15 + final ModelStatus status;
16
17 ModelInfo({
18 required this.id,
@@ -25,6 +26,7 @@ class ModelInfo {
26 required this.serverType,
27 required this.serverId,
28 this.modifiedAt,
29 + this.status = ModelStatus.unloaded,
30 });
31
32 String get displayName {
@@ -51,4 +53,34 @@ class ModelInfo {
53 if (parameterCount == null) return null;
54 return '${parameterCount}B';
55 }
56 +
57 + ModelInfo copyWith({
58 + String? id,
59 + String? name,
60 + String? description,
61 + int? parameterCount,
62 + int? contextLength,
63 + int? fileSize,
64 + String? quantization,
65 + String? architecture,
66 + ServerType? serverType,
67 + String? serverId,
68 + DateTime? modifiedAt,
69 + ModelStatus? status,
70 + }) {
71 + return ModelInfo(
72 + id: id ?? this.id,
73 + name: name ?? this.name,
74 + description: description ?? this.description,
75 + parameterCount: parameterCount ?? this.parameterCount,
76 + contextLength: contextLength ?? this.contextLength,
77 + fileSize: fileSize ?? this.fileSize,
78 + quantization: quantization ?? this.quantization,
79 + architecture: architecture ?? this.architecture,
80 + serverType: serverType ?? this.serverType,
81 + serverId: serverId ?? this.serverId,
82 + modifiedAt: modifiedAt ?? this.modifiedAt,
83 + status: status ?? this.status,
84 + );
85 + }
86 }
lib/features/models/screens/model_picker_sheet.dart
+195 -20
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/features/chat/providers/chat_providers.dart';
4 import 'package:localmind/features/models/data/models/model_info.dart';
5 import 'package:localmind/features/servers/providers/server_providers.dart';
6 +import 'package:localmind/core/providers/service_providers.dart';
7
8 final modelSearchQueryProvider = NotifierProvider<_ModelSearchNotifier, String>(
9 _ModelSearchNotifier.new,
@@ -26,6 +27,8 @@ class ModelPickerSheet extends ConsumerWidget {
27 final activeServer = ref.watch(activeServerProvider);
28 final selectedModel = ref.watch(selectedModelProvider);
29 final searchQuery = ref.watch(modelSearchQueryProvider);
30 + final modelLoading = ref.watch(modelLoadingProvider);
31 + final isThinking = ref.watch(modelThinkingProvider);
32
33 return Container(
34 height: MediaQuery.of(context).size.height * 0.7,
@@ -50,20 +53,60 @@ class ModelPickerSheet extends ConsumerWidget {
53 const SizedBox(height: 16),
54 Row(
55 children: [
53 - Text(
54 - 'Select Model',
55 - style: TextStyle(
56 - fontSize: 20,
57 - fontWeight: FontWeight.bold,
58 - color: isDark ? Colors.white : Colors.black,
56 + Expanded(
57 + child: Column(
58 + crossAxisAlignment: CrossAxisAlignment.start,
59 + children: [
60 + Row(
61 + children: [
62 + Text(
63 + 'Select Model',
64 + style: TextStyle(
65 + fontSize: 20,
66 + fontWeight: FontWeight.bold,
67 + color: isDark ? Colors.white : Colors.black,
68 + ),
69 + ),
70 + if (isThinking) ...[
71 + const SizedBox(width: 8),
72 + _ThinkingIndicator(isDark: isDark),
73 + ],
74 + ],
75 + ),
76 + if (modelLoading.isLoading)
77 + Padding(
78 + padding: const EdgeInsets.only(top: 8),
79 + child: Column(
80 + crossAxisAlignment: CrossAxisAlignment.start,
81 + children: [
82 + Text(
83 + 'Loading ${modelLoading.modelId ?? "model"}...',
84 + style: TextStyle(
85 + fontSize: 12,
86 + color: isDark
87 + ? const Color(0xFF888888)
88 + : const Color(0xFF999999),
89 + ),
90 + ),
91 + const SizedBox(height: 4),
92 + LinearProgressIndicator(
93 + value: modelLoading.progress,
94 + backgroundColor: isDark
95 + ? const Color(0xFF333333)
96 + : const Color(0xFFE0E0E0),
97 + ),
98 + ],
99 + ),
100 + ),
101 + ],
102 ),
103 ),
61 - const Spacer(),
104 if (activeServer != null)
105 IconButton(
106 icon: const Icon(Icons.refresh, size: 20),
107 onPressed: () {
108 ref.invalidate(availableModelsProvider(activeServer.id));
109 + ref.invalidate(loadedModelsProvider(activeServer));
110 },
111 tooltip: 'Refresh models',
112 ),
@@ -216,6 +259,17 @@ class _ModelList extends ConsumerWidget {
259 ),
260 ),
261 data: (models) {
262 + final servers = ref.watch(serversProvider);
263 + final activeServer = servers.where((s) => s.id == serverId).firstOrNull;
264 + final loadedModelsAsync = activeServer != null
265 + ? ref.watch(loadedModelsProvider(activeServer))
266 + : const AsyncValue<Set<String>>.data(<String>{});
267 +
268 + final loadedModels = loadedModelsAsync.maybeWhen(
269 + data: (data) => data,
270 + orElse: () => <String>{},
271 + );
272 +
273 final modelList = models.cast<ModelInfo>();
274 final filtered = searchQuery.isEmpty
275 ? modelList
@@ -250,14 +304,47 @@ class _ModelList extends ConsumerWidget {
304 itemBuilder: (context, index) {
305 final model = filtered[index];
306 final isSelected = model.id == selectedModelId;
307 + final isLoaded = loadedModels.contains(model.id);
308
309 return _ModelTile(
310 model: model,
311 isSelected: isSelected,
312 + isLoaded: isLoaded,
313 isDark: isDark,
258 - onTap: () {
314 + onTap: () async {
315 + final activeServer = ref.read(activeServerProvider);
316 + if (activeServer == null) return;
317 +
318 + if (!isLoaded) {
319 + ref.read(modelLoadingProvider.notifier).setLoading(model.id);
320 +
321 + try {
322 + final apiService = ref.read(serverApiServiceProvider);
323 + await apiService.loadModelWithInstanceId(
324 + activeServer,
325 + model.id,
326 + );
327 +
328 + ref.invalidate(loadedModelsProvider(activeServer));
329 + ref.read(modelLoadingProvider.notifier).setLoaded();
330 + } catch (e) {
331 + ref.read(modelLoadingProvider.notifier).setLoaded();
332 + if (context.mounted) {
333 + ScaffoldMessenger.of(context).showSnackBar(
334 + SnackBar(
335 + content: Text('Failed to load model: $e'),
336 + backgroundColor: Colors.red,
337 + ),
338 + );
339 + }
340 + return;
341 + }
342 + }
343 +
344 ref.read(selectedModelProvider.notifier).setModel(model);
260 - Navigator.pop(context);
345 + if (context.mounted) {
346 + Navigator.pop(context);
347 + }
348 },
349 );
350 },
@@ -271,12 +358,14 @@ class _ModelTile extends StatelessWidget {
358 const _ModelTile({
359 required this.model,
360 required this.isSelected,
361 + required this.isLoaded,
362 required this.isDark,
363 required this.onTap,
364 });
365
366 final ModelInfo model;
367 final bool isSelected;
368 + final bool isLoaded;
369 final bool isDark;
370 final VoidCallback onTap;
371
@@ -305,17 +394,33 @@ class _ModelTile extends StatelessWidget {
394 child: Column(
395 crossAxisAlignment: CrossAxisAlignment.start,
396 children: [
308 - Text(
309 - model.displayName,
310 - style: TextStyle(
311 - fontSize: 15,
312 - fontWeight: isSelected
313 - ? FontWeight.w600
314 - : FontWeight.w500,
315 - color: isDark ? Colors.white : Colors.black,
316 - ),
317 - maxLines: 1,
318 - overflow: TextOverflow.ellipsis,
397 + Row(
398 + children: [
399 + Expanded(
400 + child: Text(
401 + model.displayName,
402 + style: TextStyle(
403 + fontSize: 15,
404 + fontWeight: isSelected
405 + ? FontWeight.w600
406 + : FontWeight.w500,
407 + color: isDark ? Colors.white : Colors.black,
408 + ),
409 + maxLines: 1,
410 + overflow: TextOverflow.ellipsis,
411 + ),
412 + ),
413 + if (isLoaded)
414 + Container(
415 + width: 8,
416 + height: 8,
417 + margin: const EdgeInsets.only(left: 8),
418 + decoration: const BoxDecoration(
419 + color: Color(0xFF4CAF50),
420 + shape: BoxShape.circle,
421 + ),
422 + ),
423 + ],
424 ),
425 const SizedBox(height: 4),
426 Wrap(
@@ -364,6 +469,76 @@ class _ModelTile extends StatelessWidget {
469 }
470 }
471
472 +class _ThinkingIndicator extends StatefulWidget {
473 + const _ThinkingIndicator({required this.isDark});
474 + final bool isDark;
475 +
476 + @override
477 + State<_ThinkingIndicator> createState() => _ThinkingIndicatorState();
478 +}
479 +
480 +class _ThinkingIndicatorState extends State<_ThinkingIndicator>
481 + with SingleTickerProviderStateMixin {
482 + late AnimationController _controller;
483 +
484 + @override
485 + void initState() {
486 + super.initState();
487 + _controller = AnimationController(
488 + duration: const Duration(milliseconds: 1500),
489 + vsync: this,
490 + )..repeat();
491 + }
492 +
493 + @override
494 + void dispose() {
495 + _controller.dispose();
496 + super.dispose();
497 + }
498 +
499 + @override
500 + Widget build(BuildContext context) {
501 + return Row(
502 + mainAxisSize: MainAxisSize.min,
503 + children: [
504 + AnimatedBuilder(
505 + animation: _controller,
506 + builder: (context, child) {
507 + return Container(
508 + width: 6,
509 + height: 6,
510 + decoration: BoxDecoration(
511 + color: widget.isDark
512 + ? Color.lerp(
513 + const Color(0xFF888888),
514 + const Color(0xFF4CAF50),
515 + _controller.value,
516 + )
517 + : Color.lerp(
518 + const Color(0xFF999999),
519 + const Color(0xFF4CAF50),
520 + _controller.value,
521 + ),
522 + shape: BoxShape.circle,
523 + ),
524 + );
525 + },
526 + ),
527 + const SizedBox(width: 4),
528 + Text(
529 + 'Thinking',
530 + style: TextStyle(
531 + fontSize: 12,
532 + color: widget.isDark
533 + ? const Color(0xFF888888)
534 + : const Color(0xFF999999),
535 + ),
536 + ),
537 + ],
538 + );
539 + }
540 +}
541 +
542 class _MetadataChip extends StatelessWidget {
543 const _MetadataChip({required this.label, required this.isDark});
544 final String label;
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+9 -1
@@ -1,3 +1,5 @@
1 +import 'dart:io' show Platform;
2 +
3 import 'package:flutter/material.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import 'package:go_router/go_router.dart';
@@ -44,6 +46,10 @@ class _OnboardingServerSetupScreenState
46 defaultName = 'LM Studio';
47 defaultPort = AppConstants.lmStudioDefaultPort.toString();
48 break;
49 + case ServerType.openAICompatible:
50 + defaultName = 'Local AI';
51 + defaultPort = AppConstants.openAICompatibleDefaultPort.toString();
52 + break;
53 case ServerType.ollama:
54 defaultName = 'Ollama';
55 defaultPort = AppConstants.ollamaDefaultPort.toString();
@@ -56,7 +62,9 @@ class _OnboardingServerSetupScreenState
62
63 _nameController = TextEditingController(text: defaultName);
64 _hostController = TextEditingController(
59 - text: widget.selectedType == ServerType.openRouter ? '' : '127.0.0.1',
65 + text: widget.selectedType == ServerType.openRouter
66 + ? ''
67 + : (Platform.isAndroid ? '10.0.2.2' : '127.0.0.1'),
68 );
69 _portController = TextEditingController(text: defaultPort);
70 _apiKeyController = TextEditingController();
lib/features/servers/data/models/server.dart
+43
@@ -62,6 +62,8 @@ class Server extends HiveObject {
62 String get chatEndpoint {
63 switch (type) {
64 case ServerType.lmStudio:
65 + return '$baseUrl/api/v1/chat';
66 + case ServerType.openAICompatible:
67 return '$baseUrl/v1/chat/completions';
68 case ServerType.ollama:
69 return '$baseUrl/api/chat';
@@ -73,6 +75,8 @@ class Server extends HiveObject {
75 String get modelsEndpoint {
76 switch (type) {
77 case ServerType.lmStudio:
78 + return '$baseUrl/api/v1/models';
79 + case ServerType.openAICompatible:
80 return '$baseUrl/v1/models';
81 case ServerType.ollama:
82 return '$baseUrl/api/tags';
@@ -81,6 +85,45 @@ class Server extends HiveObject {
85 }
86 }
87
88 + String get runningModelsEndpoint {
89 + switch (type) {
90 + case ServerType.lmStudio:
91 + return '$baseUrl/api/v1/models';
92 + case ServerType.openAICompatible:
93 + return '$baseUrl/v1/models';
94 + case ServerType.ollama:
95 + return '$baseUrl/api/ps';
96 + case ServerType.openRouter:
97 + return '';
98 + }
99 + }
100 +
101 + String get loadModelEndpoint {
102 + switch (type) {
103 + case ServerType.lmStudio:
104 + return '$baseUrl/api/v1/models/load';
105 + case ServerType.openAICompatible:
106 + return '$baseUrl/v1/models/load';
107 + case ServerType.ollama:
108 + return '$baseUrl/api/load';
109 + case ServerType.openRouter:
110 + return '';
111 + }
112 + }
113 +
114 + String get unloadModelEndpoint {
115 + switch (type) {
116 + case ServerType.lmStudio:
117 + return '$baseUrl/api/v1/models/unload';
118 + case ServerType.openAICompatible:
119 + return '$baseUrl/v1/models/unload';
120 + case ServerType.ollama:
121 + return '$baseUrl/api/unload';
122 + case ServerType.openRouter:
123 + return '';
124 + }
125 + }
126 +
127 Server copyWith({
128 String? id,
129 String? name,
lib/features/servers/data/server_api_service.dart
+141 -5
@@ -46,6 +46,7 @@ class ServerApiService {
46
47 switch (server.type) {
48 case ServerType.lmStudio:
49 + case ServerType.openAICompatible:
50 return _parseLMStudioModels(response.data, server);
51 case ServerType.ollama:
52 return _parseOllamaModels(response.data, server);
@@ -57,6 +58,121 @@ class ServerApiService {
58 }
59 }
60
61 + Future<Set<String>> fetchRunningModels(Server server) async {
62 + if (server.type == ServerType.openRouter) {
63 + return {};
64 + }
65 +
66 + try {
67 + final response = await _dio.get(
68 + server.runningModelsEndpoint,
69 + options: Options(headers: _getAuthHeaders(server)),
70 + );
71 +
72 + switch (server.type) {
73 + case ServerType.lmStudio:
74 + case ServerType.openAICompatible:
75 + return _parseRunningLMStudioModels(response.data);
76 + case ServerType.ollama:
77 + return _parseRunningOllamaModels(response.data);
78 + case ServerType.openRouter:
79 + return {};
80 + }
81 + } catch (e) {
82 + return {};
83 + }
84 + }
85 +
86 + Future<void> loadModel(Server server, String modelId) async {
87 + if (server.type == ServerType.openRouter) return;
88 +
89 + switch (server.type) {
90 + case ServerType.lmStudio:
91 + case ServerType.openAICompatible:
92 + case ServerType.ollama:
93 + await _dio.post(
94 + server.loadModelEndpoint,
95 + data: {'model': modelId},
96 + options: Options(headers: _getAuthHeaders(server)),
97 + );
98 + case ServerType.openRouter:
99 + break;
100 + }
101 + }
102 +
103 + Future<String?> loadModelWithInstanceId(Server server, String modelId) async {
104 + if (server.type == ServerType.openRouter) return null;
105 +
106 + switch (server.type) {
107 + case ServerType.lmStudio:
108 + case ServerType.openAICompatible:
109 + final response = await _dio.post(
110 + server.loadModelEndpoint,
111 + data: {'model': modelId, 'echo_load_config': true},
112 + options: Options(headers: _getAuthHeaders(server)),
113 + );
114 + return response.data['instance_id'] as String?;
115 + case ServerType.ollama:
116 + await _dio.post(
117 + server.loadModelEndpoint,
118 + data: {'model': modelId},
119 + options: Options(headers: _getAuthHeaders(server)),
120 + );
121 + return null;
122 + case ServerType.openRouter:
123 + return null;
124 + }
125 + }
126 +
127 + Future<void> unloadModel(
128 + Server server,
129 + String modelId, {
130 + String? instanceId,
131 + }) async {
132 + if (server.type == ServerType.openRouter) return;
133 +
134 + switch (server.type) {
135 + case ServerType.lmStudio:
136 + case ServerType.openAICompatible:
137 + await _dio.post(
138 + server.unloadModelEndpoint,
139 + data: {'instance_id': instanceId ?? modelId},
140 + options: Options(headers: _getAuthHeaders(server)),
141 + );
142 + case ServerType.ollama:
143 + await _dio.post(
144 + server.unloadModelEndpoint,
145 + data: {'model': modelId},
146 + options: Options(headers: _getAuthHeaders(server)),
147 + );
148 + case ServerType.openRouter:
149 + break;
150 + }
151 + }
152 +
153 + Set<String> _parseRunningLMStudioModels(dynamic data) {
154 + final runningModels = <String>{};
155 + if (data['models'] != null) {
156 + for (final item in data['models']) {
157 + final loadedInstances = item['loaded_instances'] as List?;
158 + if (loadedInstances != null && loadedInstances.isNotEmpty) {
159 + runningModels.add(item['key'] as String? ?? '');
160 + }
161 + }
162 + }
163 + return runningModels;
164 + }
165 +
166 + Set<String> _parseRunningOllamaModels(dynamic data) {
167 + final runningModels = <String>{};
168 + if (data['models'] != null) {
169 + for (final item in data['models']) {
170 + runningModels.add(item['name'] as String? ?? '');
171 + }
172 + }
173 + return runningModels;
174 + }
175 +
176 Map<String, String>? _getAuthHeaders(Server server) {
177 if (server.apiKey != null && server.apiKey!.isNotEmpty) {
178 return {'Authorization': 'Bearer ${server.apiKey}'};
@@ -66,16 +182,25 @@ class ServerApiService {
182
183 List<ModelInfo> _parseLMStudioModels(dynamic data, Server server) {
184 final List<ModelInfo> models = [];
69 - if (data['data'] != null) {
70 - for (final item in data['data']) {
71 - final id = item['id'] as String? ?? '';
185 + if (data['models'] != null) {
186 + for (final item in data['models']) {
187 + final id = item['key'] as String? ?? '';
188 + final displayName = item['display_name'] as String?;
189 + final quantization = item['quantization'];
190 + final paramsString = item['params_string'] as String?;
191 +
192 models.add(
193 ModelInfo(
194 id: id,
75 - name: _formatModelName(id),
195 + name: displayName ?? _formatModelName(id),
196 + description: item['description'] as String?,
197 + parameterCount: _parseParameterString(paramsString),
198 + contextLength: item['max_context_length'] as int?,
199 + fileSize: item['size_bytes'] as int?,
200 + quantization: quantization?['name'] as String?,
201 + architecture: item['architecture'] as String?,
202 serverType: server.type,
203 serverId: server.id,
78 - parameterCount: _extractParameterCount(id),
204 ),
205 );
206 }
@@ -156,4 +281,15 @@ class ServerApiService {
281 final cleaned = size.replaceAll('B', '').replaceAll('b', '').trim();
282 return int.tryParse(cleaned);
283 }
284 +
285 + int? _parseParameterString(String? paramsString) {
286 + if (paramsString == null) return null;
287 + final regex = RegExp(r'(\d+(?:\.\d+)?)\s*[Bb]');
288 + final match = regex.firstMatch(paramsString);
289 + if (match != null) {
290 + final value = double.tryParse(match.group(1) ?? '');
291 + return value?.toInt();
292 + }
293 + return null;
294 + }
295 }
lib/features/servers/providers/server_providers.dart
+27
@@ -22,6 +22,33 @@ final connectionStatusProvider =
22 return ConnectionStatusNotifier();
23 });
24
25 +final loadedModelsRefreshProvider =
26 + NotifierProvider<LoadedModelsRefreshNotifier, int>(() {
27 + return LoadedModelsRefreshNotifier();
28 + });
29 +
30 +class LoadedModelsRefreshNotifier extends Notifier<int> {
31 + @override
32 + int build() => 0;
33 +
34 + void refresh() {
35 + state++;
36 + }
37 +}
38 +
39 +final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
40 + ref,
41 + server,
42 +) async {
43 + ref.watch(loadedModelsRefreshProvider);
44 + final apiService = ref.watch(serverApiServiceProvider);
45 + try {
46 + return await apiService.fetchRunningModels(server);
47 + } catch (e) {
48 + return {};
49 + }
50 +});
51 +
52 final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
53 ref,
54 serverId,
lib/features/servers/views/add_server_screen.dart
+3
@@ -69,6 +69,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
69 _portController.text = '443';
70 } else if (type == ServerType.lmStudio) {
71 _portController.text = AppConstants.lmStudioDefaultPort.toString();
72 + } else if (type == ServerType.openAICompatible) {
73 + _portController.text = AppConstants.openAICompatibleDefaultPort
74 + .toString();
75 } else if (type == ServerType.ollama) {
76 _portController.text = AppConstants.ollamaDefaultPort.toString();
77 }
lib/features/servers/views/components/server_card.dart
+2
@@ -34,6 +34,8 @@ class ServerCard extends StatelessWidget {
34 switch (server.type) {
35 case ServerType.lmStudio:
36 return 'LM Studio';
37 + case ServerType.openAICompatible:
38 + return 'OpenAI Compatible';
39 case ServerType.ollama:
40 return 'Ollama';
41 case ServerType.openRouter:
lib/features/servers/views/components/server_type_selector.dart
+5
@@ -20,6 +20,11 @@ class ServerTypeSelector extends StatelessWidget {
20 label: Text('LM Studio'),
21 icon: Icon(Icons.terminal, size: 18),
22 ),
23 + ButtonSegment<ServerType>(
24 + value: ServerType.openAICompatible,
25 + label: Text('OpenAI'),
26 + icon: Icon(Icons.api, size: 18),
27 + ),
28 ButtonSegment<ServerType>(
29 value: ServerType.ollama,
30 label: Text('Ollama'),
lib/features/settings/data/models/app_settings.dart
+10 -2
@@ -50,6 +50,9 @@ class AppSettings extends HiveObject {
50 @HiveField(14, defaultValue: false)
51 final bool hasCompletedOnboarding;
52
53 + @HiveField(15, defaultValue: true)
54 + final bool mcpEnabled;
55 +
56 AppSettings({
57 this.temperature = 0.7,
58 this.topP = 0.9,
@@ -66,6 +69,7 @@ class AppSettings extends HiveObject {
69 this.streamingEnabled = true,
70 this.defaultPersonaId,
71 this.hasCompletedOnboarding = false,
72 + this.mcpEnabled = true,
73 });
74
75 AppSettings copyWith({
@@ -84,6 +88,7 @@ class AppSettings extends HiveObject {
88 bool? streamingEnabled,
89 String? defaultPersonaId,
90 bool? hasCompletedOnboarding,
91 + bool? mcpEnabled,
92 }) {
93 return AppSettings(
94 temperature: temperature ?? this.temperature,
@@ -93,14 +98,17 @@ class AppSettings extends HiveObject {
98 themeMode: themeMode ?? this.themeMode,
99 fontSize: fontSize ?? this.fontSize,
100 showSystemMessages: showSystemMessages ?? this.showSystemMessages,
96 - hapticFeedbackEnabled: hapticFeedbackEnabled ?? this.hapticFeedbackEnabled,
101 + hapticFeedbackEnabled:
102 + hapticFeedbackEnabled ?? this.hapticFeedbackEnabled,
103 sendOnEnter: sendOnEnter ?? this.sendOnEnter,
104 defaultServerId: defaultServerId ?? this.defaultServerId,
105 showDataIndicator: showDataIndicator ?? this.showDataIndicator,
106 autoGenerateTitle: autoGenerateTitle ?? this.autoGenerateTitle,
107 streamingEnabled: streamingEnabled ?? this.streamingEnabled,
108 defaultPersonaId: defaultPersonaId ?? this.defaultPersonaId,
103 - hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding,
109 + hasCompletedOnboarding:
110 + hasCompletedOnboarding ?? this.hasCompletedOnboarding,
111 + mcpEnabled: mcpEnabled ?? this.mcpEnabled,
112 );
113 }
114 }
lib/features/settings/data/models/app_settings.g.dart
+5 -2
@@ -32,13 +32,14 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
32 streamingEnabled: fields[12] == null ? true : fields[12] as bool,
33 defaultPersonaId: fields[13] as String?,
34 hasCompletedOnboarding: fields[14] == null ? false : fields[14] as bool,
35 + mcpEnabled: fields[15] == null ? true : fields[15] as bool,
36 );
37 }
38
39 @override
40 void write(BinaryWriter writer, AppSettings obj) {
41 writer
41 - ..writeByte(15)
42 + ..writeByte(16)
43 ..writeByte(0)
44 ..write(obj.temperature)
45 ..writeByte(1)
@@ -68,7 +69,9 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
69 ..writeByte(13)
70 ..write(obj.defaultPersonaId)
71 ..writeByte(14)
71 - ..write(obj.hasCompletedOnboarding);
72 + ..write(obj.hasCompletedOnboarding)
73 + ..writeByte(15)
74 + ..write(obj.mcpEnabled);
75 }
76
77 @override
lib/features/settings/screens/settings_screen.dart
+7
@@ -119,6 +119,13 @@ class SettingsScreen extends ConsumerWidget {
119 ref.read(settingsProvider.notifier).setHapticFeedback(v),
120 isDark: isDark,
121 ),
122 + _ToggleSetting(
123 + label: 'Enable MCP',
124 + value: settings.mcpEnabled,
125 + onChanged: (v) =>
126 + ref.read(settingsProvider.notifier).setMcpEnabled(v),
127 + isDark: isDark,
128 + ),
129 const Divider(height: 32),
130 _SectionHeader(title: 'Default Server'),
131 _DropdownSetting(
test_icon.dart deleted
-6
@@ -1,6 +0,0 @@
1 -import 'package:hugeicons/hugeicons.dart';
2 -import 'package:hugeicons/styles/stroke_rounded.dart';
3 -
4 -void main() {
5 - print('Icon type: ${HugeIconsStrokeRounded.serverStack01.runtimeType}');
6 -}