6 files changed
+170
lib/features/models/components/model_tile.dart
+69
@@ -3,6 +3,7 @@ import 'package:localmind/core/models/enums.dart';
3
import 'package:localmind/core/theme/colors.dart';
4
import 'package:localmind/features/models/data/models/model_info.dart';
5
import 'package:localmind/l10n/app_localizations.dart';
6
+import 'package:shadcn_ui/shadcn_ui.dart';
7
import 'metadata_chip.dart';
8
9
class ModelTile extends StatelessWidget {
@@ -65,6 +66,12 @@ class ModelTile extends StatelessWidget {
66
overflow: TextOverflow.ellipsis,
67
),
68
),
69
+ if (model.supportsVision ||
70
+ model.supportsReasoning ||
71
+ model.supportsToolUse) ...[
72
+ const SizedBox(width: 8),
73
+ _ModelCapabilityIcons(model: model, isDark: isDark),
74
+ ],
75
],
76
),
77
const SizedBox(height: 4),
@@ -143,3 +150,65 @@ class ModelTile extends StatelessWidget {
150
);
151
}
152
}
153
+
154
+class _ModelCapabilityIcons extends StatelessWidget {
155
+ const _ModelCapabilityIcons({
156
+ required this.model,
157
+ required this.isDark,
158
+ });
159
+
160
+ final ModelInfo model;
161
+ final bool isDark;
162
+
163
+ @override
164
+ Widget build(BuildContext context) {
165
+ final color = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
166
+
167
+ return Row(
168
+ mainAxisSize: MainAxisSize.min,
169
+ children: [
170
+ if (model.supportsVision)
171
+ _CapabilityIcon(
172
+ icon: LucideIcons.eye,
173
+ tooltip: 'Vision',
174
+ color: color,
175
+ ),
176
+ if (model.supportsReasoning)
177
+ _CapabilityIcon(
178
+ icon: LucideIcons.brain,
179
+ tooltip: 'Reasoning',
180
+ color: color,
181
+ ),
182
+ if (model.supportsToolUse)
183
+ _CapabilityIcon(
184
+ icon: LucideIcons.hammer,
185
+ tooltip: 'Tool use',
186
+ color: color,
187
+ ),
188
+ ],
189
+ );
190
+ }
191
+}
192
+
193
+class _CapabilityIcon extends StatelessWidget {
194
+ const _CapabilityIcon({
195
+ required this.icon,
196
+ required this.tooltip,
197
+ required this.color,
198
+ });
199
+
200
+ final IconData icon;
201
+ final String tooltip;
202
+ final Color color;
203
+
204
+ @override
205
+ Widget build(BuildContext context) {
206
+ return Padding(
207
+ padding: const EdgeInsets.only(left: 4),
208
+ child: Tooltip(
209
+ message: tooltip,
210
+ child: Icon(icon, size: 14, color: color),
211
+ ),
212
+ );
213
+ }
214
+}
lib/features/models/data/models/model_info.dart
+12
@@ -17,6 +17,9 @@ class ModelInfo {
17
final OnDeviceModelRuntime? onDeviceRuntime;
18
final OnDeviceModelFormat? onDeviceFormat;
19
final String? localPath;
20
+ final bool supportsVision;
21
+ final bool supportsReasoning;
22
+ final bool supportsToolUse;
23
24
ModelInfo({
25
required this.id,
@@ -34,6 +37,9 @@ class ModelInfo {
37
this.onDeviceRuntime,
38
this.onDeviceFormat,
39
this.localPath,
40
+ this.supportsVision = false,
41
+ this.supportsReasoning = false,
42
+ this.supportsToolUse = false,
43
});
44
45
String get displayName {
@@ -83,6 +89,9 @@ class ModelInfo {
89
OnDeviceModelRuntime? onDeviceRuntime,
90
OnDeviceModelFormat? onDeviceFormat,
91
String? localPath,
92
+ bool? supportsVision,
93
+ bool? supportsReasoning,
94
+ bool? supportsToolUse,
95
}) {
96
return ModelInfo(
97
id: id ?? this.id,
@@ -100,6 +109,9 @@ class ModelInfo {
109
onDeviceRuntime: onDeviceRuntime ?? this.onDeviceRuntime,
110
onDeviceFormat: onDeviceFormat ?? this.onDeviceFormat,
111
localPath: localPath ?? this.localPath,
112
+ supportsVision: supportsVision ?? this.supportsVision,
113
+ supportsReasoning: supportsReasoning ?? this.supportsReasoning,
114
+ supportsToolUse: supportsToolUse ?? this.supportsToolUse,
115
);
116
}
117
}
lib/features/on_device/components/on_device_picker_section.dart
+6
@@ -513,6 +513,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
513
onDeviceRuntime: model.runtime,
514
onDeviceFormat: model.format,
515
localPath: model.localPath,
516
+ supportsVision: model.supportsVision,
517
+ supportsReasoning: model.supportsThinking,
518
+ supportsToolUse: model.supportsFunctionCalling,
519
);
520
521
await engineNotifier.loadModel(model.id, settings.preferredBackend);
@@ -578,6 +581,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
581
onDeviceRuntime: model.runtime,
582
onDeviceFormat: model.format,
583
localPath: model.localPath,
584
+ supportsVision: model.supportsVision,
585
+ supportsReasoning: model.supportsThinking,
586
+ supportsToolUse: model.supportsFunctionCalling,
587
);
588
589
ref.read(selectedModelProvider.notifier).setModel(modelInfo);
lib/features/servers/data/server_api_service.dart
+26
@@ -282,6 +282,8 @@ class ServerApiService {
282
archName = archValue;
283
}
284
285
+ final capabilities = _parseModelCapabilities(item['capabilities']);
286
+
287
models.add(
288
ModelInfo(
289
id: id,
@@ -297,6 +299,9 @@ class ServerApiService {
299
architecture: archName,
300
serverType: server.type,
301
serverId: server.id,
302
+ supportsVision: capabilities.supportsVision,
303
+ supportsReasoning: capabilities.supportsReasoning,
304
+ supportsToolUse: capabilities.supportsToolUse,
305
),
306
);
307
}
@@ -312,6 +317,27 @@ class ServerApiService {
317
return null;
318
}
319
320
+ ({
321
+ bool supportsVision,
322
+ bool supportsReasoning,
323
+ bool supportsToolUse,
324
+ }) _parseModelCapabilities(dynamic raw) {
325
+ if (raw is! Map) {
326
+ return (
327
+ supportsVision: false,
328
+ supportsReasoning: false,
329
+ supportsToolUse: false,
330
+ );
331
+ }
332
+
333
+ final reasoning = raw['reasoning'];
334
+ return (
335
+ supportsVision: raw['vision'] == true,
336
+ supportsReasoning: reasoning is Map && reasoning.isNotEmpty,
337
+ supportsToolUse: raw['trained_for_tool_use'] == true,
338
+ );
339
+ }
340
+
341
double? _paramCountFromMeta(dynamic nParams) {
342
if (nParams == null) return null;
343
if (nParams is int) return nParams / 1000000000;
lib/features/servers/providers/server_providers.dart
+3
@@ -145,6 +145,9 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
145
onDeviceRuntime: m.runtime,
146
onDeviceFormat: m.format,
147
localPath: m.localPath,
148
+ supportsVision: m.supportsVision,
149
+ supportsReasoning: m.supportsThinking,
150
+ supportsToolUse: m.supportsFunctionCalling,
151
),
152
)
153
.toList();
test/features/servers/server_api_service_test.dart
+54
@@ -134,6 +134,60 @@ void main() {
134
expect(model.architecture, "llama");
135
});
136
137
+ test('parses model capabilities from LM Studio models array', () async {
138
+ final mockData = {
139
+ "models": [
140
+ {
141
+ "key": "google/gemma-4-e2b",
142
+ "display_name": "Gemma 4 E2B",
143
+ "capabilities": {
144
+ "vision": true,
145
+ "trained_for_tool_use": true,
146
+ },
147
+ },
148
+ {
149
+ "key": "mistralai/ministral-3-14b-reasoning",
150
+ "display_name": "Ministral 3 14B Reasoning",
151
+ "capabilities": {
152
+ "vision": true,
153
+ "trained_for_tool_use": true,
154
+ "reasoning": {
155
+ "allowed_options": ["off", "on"],
156
+ "default": "on",
157
+ },
158
+ },
159
+ },
160
+ {
161
+ "key": "mradermacher/kimi-vl-a3b-thinking-2506-i1",
162
+ "display_name": "Kimi VL A3B Thinking 2506 I1",
163
+ "capabilities": {
164
+ "vision": false,
165
+ "trained_for_tool_use": false,
166
+ },
167
+ },
168
+ ],
169
+ };
170
+
171
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
172
+ final service = ServerApiService(dio);
173
+
174
+ final models = await service.fetchModels(testServer);
175
+
176
+ expect(models, hasLength(3));
177
+
178
+ expect(models[0].supportsVision, isTrue);
179
+ expect(models[0].supportsToolUse, isTrue);
180
+ expect(models[0].supportsReasoning, isFalse);
181
+
182
+ expect(models[1].supportsVision, isTrue);
183
+ expect(models[1].supportsToolUse, isTrue);
184
+ expect(models[1].supportsReasoning, isTrue);
185
+
186
+ expect(models[2].supportsVision, isFalse);
187
+ expect(models[2].supportsToolUse, isFalse);
188
+ expect(models[2].supportsReasoning, isFalse);
189
+ });
190
+
191
test('handles running models with fallback data array (llama.cpp)', () async {
192
final mockData = {
193
"object": "list",