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
-}