18 files changed
+570
-77
lib/features/chat/providers/model_selection_providers.dart
+28
-2
@@ -76,7 +76,7 @@ final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) {
76
: null;
77
final model = modelId == null
78
? null
79
- : OnDeviceModel.curatedModels
79
+ : OnDeviceModel.allCuratedModels
80
.where((candidate) => candidate.id == modelId)
81
.firstOrNull;
82
@@ -248,7 +248,33 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
248
return;
249
}
250
251
- if (loadedModels.isEmpty) return;
251
+ if (loadedModels.isEmpty) {
252
+ if (activeServer.type == ServerType.onDevice) {
253
+ final downloadedAsync = await ref.read(downloadedModelsProvider.future);
254
+ if (!ref.mounted) return;
255
+ if (hasDefaultForServer) {
256
+ final defaultModel = typedModels
257
+ .where(
258
+ (m) =>
259
+ m.id == defaultModelId && downloadedAsync.contains(m.id),
260
+ )
261
+ .firstOrNull;
262
+ if (defaultModel != null && ref.mounted) {
263
+ ref.read(selectedModelProvider.notifier).setModel(defaultModel);
264
+ return;
265
+ }
266
+ }
267
+ final firstAvailableModel = typedModels
268
+ .where((m) => downloadedAsync.contains(m.id))
269
+ .firstOrNull;
270
+ if (firstAvailableModel != null && ref.mounted) {
271
+ ref
272
+ .read(selectedModelProvider.notifier)
273
+ .setModel(firstAvailableModel);
274
+ }
275
+ }
276
+ return;
277
+ }
278
279
final firstLoadedModel = typedModels
280
.where((m) => isModelKeyLoaded(loadedModels, m.id))
lib/features/on_device/components/model_card.dart
+27
-6
@@ -182,6 +182,24 @@ class _ModelCardHeader extends StatelessWidget {
182
),
183
),
184
),
185
+ if (model.isBuiltIn)
186
+ Container(
187
+ margin: const EdgeInsets.only(left: 6),
188
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
189
+ decoration: BoxDecoration(
190
+ color: Colors.teal.withValues(alpha: 0.1),
191
+ borderRadius: BorderRadius.circular(4),
192
+ ),
193
+ child: Text(
194
+ Platform.isIOS || Platform.isMacOS
195
+ ? ' Apple Intelligence'
196
+ : '⚡ Built-in AI',
197
+ style: theme.textTheme.labelSmall?.copyWith(
198
+ color: Colors.teal,
199
+ fontWeight: FontWeight.bold,
200
+ ),
201
+ ),
202
+ ),
203
],
204
);
205
}
@@ -235,6 +253,7 @@ class _CapabilityChips extends StatelessWidget {
253
if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
254
if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
255
if (model.runtime == OnDeviceModelRuntime.mlx) ' Metal GPU',
256
+ if (model.isBuiltIn) '⚡ Built-in',
257
if (model.supportsFunctionCalling) 'Tools',
258
if (model.supportsThinking) 'Thinking',
259
if (model.supportsVision) 'Vision',
@@ -483,12 +502,14 @@ class _DownloadedActions extends ConsumerWidget {
502
onPressed: () => _loadModel(context, ref),
503
child: Text(l10n.load),
504
),
486
- const SizedBox(width: 8),
487
- ShadButton.outline(
488
- size: ShadButtonSize.sm,
489
- onPressed: () => _deleteModel(context, ref),
490
- child: Text(l10n.delete),
491
- ),
505
+ if (!model.isBuiltIn) ...[
506
+ const SizedBox(width: 8),
507
+ ShadButton.outline(
508
+ size: ShadButtonSize.sm,
509
+ onPressed: () => _deleteModel(context, ref),
510
+ child: Text(l10n.delete),
511
+ ),
512
+ ],
513
],
514
),
515
if (isLoading) ...[
lib/features/on_device/components/on_device_picker_section.dart
+40
-11
@@ -347,6 +347,28 @@ class _OnDeviceModelTile extends ConsumerWidget {
347
),
348
),
349
),
350
+ if (model.isBuiltIn)
351
+ Container(
352
+ margin: const EdgeInsets.only(left: 6),
353
+ padding: const EdgeInsets.symmetric(
354
+ horizontal: 6,
355
+ vertical: 2,
356
+ ),
357
+ decoration: BoxDecoration(
358
+ color: Colors.teal.withValues(alpha: 0.1),
359
+ borderRadius: BorderRadius.circular(4),
360
+ ),
361
+ child: Text(
362
+ Platform.isIOS || Platform.isMacOS
363
+ ? ' Built-in'
364
+ : '⚡ Built-in',
365
+ style: const TextStyle(
366
+ fontSize: 9,
367
+ fontWeight: FontWeight.bold,
368
+ color: Colors.teal,
369
+ ),
370
+ ),
371
+ ),
372
],
373
),
374
if (!Platform.isIOS &&
@@ -413,6 +435,11 @@ class _OnDeviceModelTile extends ConsumerWidget {
435
label: model.fileSizeFormatted,
436
isDark: isDark,
437
),
438
+ if (model.isBuiltIn)
439
+ MetadataChip(
440
+ label: '⚡ Built-in',
441
+ isDark: isDark,
442
+ ),
443
if (model.format == OnDeviceModelFormat.gguf)
444
MetadataChip(
445
label: l10n.gguf_format_label,
@@ -475,18 +502,20 @@ class _OnDeviceModelTile extends ConsumerWidget {
502
tooltip: l10n.load,
503
onPressed: () => _loadModel(context, ref),
504
),
478
- const SizedBox(width: 4),
479
- _IconButton(
480
- icon: HugeIcon(
481
- icon: HugeIcons.strokeRoundedDelete01,
482
- size: 18,
483
- color: isDark
484
- ? AppColors.darkMutedText
485
- : AppColors.lightMutedText,
505
+ if (!model.isBuiltIn) ...[
506
+ const SizedBox(width: 4),
507
+ _IconButton(
508
+ icon: HugeIcon(
509
+ icon: HugeIcons.strokeRoundedDelete01,
510
+ size: 18,
511
+ color: isDark
512
+ ? AppColors.darkMutedText
513
+ : AppColors.lightMutedText,
514
+ ),
515
+ tooltip: l10n.delete,
516
+ onPressed: () => _deleteModel(context, ref),
517
),
487
- tooltip: l10n.delete,
488
- onPressed: () => _deleteModel(context, ref),
489
- ),
518
+ ],
519
] else if (isDownloading) ...[
520
_IconButton(
521
icon: HugeIcon(
lib/features/on_device/data/models/on_device_model.dart
+61
-1
@@ -4,7 +4,7 @@ import '../../../../core/models/enums.dart';
4
5
enum OnDeviceModelRuntime { gemma, llamaCpp, mlx }
6
7
-enum OnDeviceModelFormat { litertlm, task, binary, gguf, mlx }
7
+enum OnDeviceModelFormat { litertlm, task, binary, gguf, mlx, builtIn }
8
9
enum OnDeviceImportedSource { localFile, huggingFace }
10
@@ -60,6 +60,9 @@ class OnDeviceModel {
60
});
61
62
String get fileSizeFormatted {
63
+ if (isBuiltIn || fileSizeBytes == 0) {
64
+ return 'Built-in';
65
+ }
66
final gb = fileSizeBytes / (1024 * 1024 * 1024);
67
if (gb >= 1) {
68
return '${gb.toStringAsFixed(2)} GB';
@@ -69,12 +72,16 @@ class OnDeviceModel {
72
}
73
74
String get fileName {
75
+ if (isBuiltIn) {
76
+ return id;
77
+ }
78
if (localPath != null && localPath!.isNotEmpty) {
79
return localPath!.split('/').last;
80
}
81
return huggingFaceUrl.split('/').last;
82
}
83
84
+ bool get isBuiltIn => format == OnDeviceModelFormat.builtIn;
85
bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
86
bool get isMlx => runtime == OnDeviceModelRuntime.mlx;
87
@@ -386,6 +393,59 @@ class OnDeviceModel {
393
format: OnDeviceModelFormat.mlx,
394
),
395
];
396
+
397
+ static const OnDeviceModel geminiNanoBuiltIn = OnDeviceModel(
398
+ id: 'gemini-nano',
399
+ name: 'Gemini Nano (System AI)',
400
+ huggingFaceUrl: '',
401
+ fileSizeBytes: 0,
402
+ license: 'System / Google',
403
+ description:
404
+ 'Google on-device foundation model powered by Android AICore / Chrome Prompt API. Zero download required.',
405
+ isRecommended: true,
406
+ minRamMb: 0,
407
+ parameterLabel: 'System',
408
+ bestFor: 'Built-in on-device system AI (zero download)',
409
+ supportsFunctionCalling: true,
410
+ supportsThinking: false,
411
+ supportsVision: true,
412
+ languagesLabel: 'Multilingual',
413
+ backendNote: 'System AI',
414
+ format: OnDeviceModelFormat.builtIn,
415
+ runtime: OnDeviceModelRuntime.gemma,
416
+ );
417
+
418
+ static const OnDeviceModel appleFoundationModelsBuiltIn = OnDeviceModel(
419
+ id: 'apple-foundation-models',
420
+ name: 'Apple Foundation Models',
421
+ huggingFaceUrl: '',
422
+ fileSizeBytes: 0,
423
+ license: 'Apple Intelligence',
424
+ description:
425
+ 'Apple on-device system foundation model powered by Apple Intelligence. Zero download required.',
426
+ isRecommended: true,
427
+ minRamMb: 0,
428
+ parameterLabel: 'System',
429
+ bestFor: 'Built-in Apple Intelligence on-device model (zero download)',
430
+ supportsFunctionCalling: true,
431
+ supportsThinking: false,
432
+ supportsVision: false,
433
+ languagesLabel: 'Multilingual',
434
+ backendNote: 'Apple Intelligence',
435
+ format: OnDeviceModelFormat.builtIn,
436
+ runtime: OnDeviceModelRuntime.gemma,
437
+ );
438
+
439
+ static const List<OnDeviceModel> curatedBuiltInModels = [
440
+ geminiNanoBuiltIn,
441
+ appleFoundationModelsBuiltIn,
442
+ ];
443
+
444
+ static List<OnDeviceModel> get allCuratedModels => [
445
+ ...curatedBuiltInModels,
446
+ ...curatedMlxModels,
447
+ ...curatedModels,
448
+ ];
449
}
450
451
class DownloadedModel {
lib/features/on_device/data/on_device_gemma_service.dart
+65
-16
@@ -1,6 +1,7 @@
1
import 'dart:io';
2
3
import 'package:flutter_gemma/flutter_gemma.dart';
4
+import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
5
import 'package:flutter_gemma_litertlm/flutter_gemma_litertlm.dart';
6
import 'package:path_provider/path_provider.dart';
7
@@ -71,8 +72,8 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
72
final specName = restoredActiveModelName;
73
if (specName == null) return;
74
74
- final model = OnDeviceModel.curatedModels.firstWhere(
75
- (m) => m.fileName == specName,
75
+ final model = OnDeviceModel.allCuratedModels.firstWhere(
76
+ (m) => m.fileName == specName || m.id == specName,
77
orElse: () => throw StateError('No curated model for $specName'),
78
);
79
@@ -82,7 +83,10 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
83
84
static Future<void> initialize({String? huggingFaceToken}) async {
85
await FlutterGemma.initialize(
85
- inferenceEngines: const [LiteRtLmEngine()],
86
+ inferenceEngines: const [
87
+ LiteRtLmEngine(),
88
+ BuiltInAiEngine(),
89
+ ],
90
huggingFaceToken: huggingFaceToken,
91
maxDownloadRetries: 10,
92
);
@@ -93,6 +97,19 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
97
void Function(int)? onProgress,
98
String? token,
99
}) async {
100
+ if (model.isBuiltIn) {
101
+ Log.info('Installing built-in model ${model.id}');
102
+ await BuiltInAi.ensureReady(
103
+ onProgress: onProgress,
104
+ );
105
+ await FlutterGemma.installModel(
106
+ modelType: ModelType.general,
107
+ fileType: ModelFileType.builtIn,
108
+ ).fromBundled(model.fileName).install();
109
+ Log.info('Built-in model ${model.id} ready');
110
+ return;
111
+ }
112
+
113
Log.info('Installing model ${model.id} from ${model.huggingFaceUrl}');
114
115
await FlutterGemma.installModel(
@@ -109,10 +126,18 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
126
}
127
128
Future<bool> isModelInstalled(String modelId) async {
112
- final model = OnDeviceModel.curatedModels.firstWhere(
113
- (m) => m.id == modelId,
129
+ final model = OnDeviceModel.allCuratedModels.firstWhere(
130
+ (m) => m.id == modelId || m.fileName == modelId,
131
orElse: () => throw Exception('Model not found: $modelId'),
132
);
133
+ if (model.isBuiltIn) {
134
+ try {
135
+ final availability = await BuiltInAi.availability();
136
+ if (availability == BuiltInAiAvailability.available) {
137
+ return true;
138
+ }
139
+ } catch (_) {}
140
+ }
141
return FlutterGemma.isModelInstalled(model.fileName);
142
}
143
@@ -121,7 +146,7 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
146
final installedSet = installed.toSet();
147
final installedIds = <String>[];
148
124
- for (final model in OnDeviceModel.curatedModels) {
149
+ for (final model in OnDeviceModel.allCuratedModels) {
150
if (installedSet.contains(model.fileName)) {
151
installedIds.add(model.id);
152
}
@@ -130,12 +155,17 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
155
}
156
157
Future<void> deleteModel(String modelId) async {
133
- final model = OnDeviceModel.curatedModels.firstWhere(
134
- (m) => m.id == modelId,
158
+ final model = OnDeviceModel.allCuratedModels.firstWhere(
159
+ (m) => m.id == modelId || m.fileName == modelId,
160
orElse: () => throw Exception('Model not found: $modelId'),
161
);
162
await FlutterGemma.uninstallModel(model.fileName);
163
164
+ if (model.isBuiltIn) {
165
+ Log.info('Built-in model $modelId uninstalled');
166
+ return;
167
+ }
168
+
169
// Also clean up old custom download location if it exists
170
try {
171
final dir = await getApplicationSupportDirectory();
@@ -174,17 +204,36 @@ class OnDeviceGemmaService implements OnDeviceInferenceService {
204
'Loading model $modelId with backend=$backend, maxTokens=$maxTokens',
205
);
206
177
- final model = OnDeviceModel.curatedModels.firstWhere(
178
- (m) => m.id == modelId,
207
+ final model = OnDeviceModel.allCuratedModels.firstWhere(
208
+ (m) => m.id == modelId || m.fileName == modelId,
209
orElse: () => throw Exception('Model not found: $modelId'),
210
);
211
182
- final spec = InferenceModelSpec(
183
- name: model.fileName,
184
- modelSource: ModelSource.network(model.huggingFaceUrl),
185
- modelType: model.flutterGemmaModelType,
186
- fileType: ModelFileType.litertlm,
187
- );
212
+ final InferenceModelSpec spec;
213
+ if (model.isBuiltIn) {
214
+ await BuiltInAi.ensureReady();
215
+ final installed = await FlutterGemma.isModelInstalled(model.fileName);
216
+ if (!installed) {
217
+ await FlutterGemma.installModel(
218
+ modelType: ModelType.general,
219
+ fileType: ModelFileType.builtIn,
220
+ ).fromBundled(model.fileName).install();
221
+ }
222
+ spec = InferenceModelSpec(
223
+ name: model.fileName,
224
+ modelSource: ModelSource.bundled(model.fileName),
225
+ modelType: ModelType.general,
226
+ fileType: ModelFileType.builtIn,
227
+ );
228
+ } else {
229
+ spec = InferenceModelSpec(
230
+ name: model.fileName,
231
+ modelSource: ModelSource.network(model.huggingFaceUrl),
232
+ modelType: model.flutterGemmaModelType,
233
+ fileType: ModelFileType.litertlm,
234
+ );
235
+ }
236
+
237
FlutterGemmaPlugin.instance.modelManager.setActiveModel(spec);
238
239
_model = await FlutterGemma.getActiveModel(
lib/features/on_device/data/on_device_mlx_service.dart
+11
-7
@@ -4,6 +4,7 @@ import 'dart:io';
4
5
import 'package:apple_mlx/apple_mlx.dart';
6
import 'package:dio/dio.dart';
7
+import 'package:flutter/foundation.dart';
8
import 'package:path/path.dart' as p;
9
import 'package:path_provider/path_provider.dart';
10
@@ -23,12 +24,15 @@ class OnDeviceMlxService {
24
this.modelsDirectoryProvider,
25
AppleMlxLlmManager Function()? llmManagerFactory,
26
bool? nativeInferenceAvailable,
27
+ bool? isApplePlatform,
28
bool? isIOS,
29
}) : _llmManagerFactory = llmManagerFactory ?? AppleMlxLlmManager.new,
30
_nativeInferenceAvailable =
31
nativeInferenceAvailable ??
32
AppleMlxCapabilities.nativeLlmInferenceAvailable,
31
- _isIOS = isIOS ?? Platform.isIOS;
33
+ _isApplePlatform =
34
+ isApplePlatform ??
35
+ (isIOS ?? (!kIsWeb && (Platform.isIOS || Platform.isMacOS)));
36
37
static const String _downloadCompleteFileName = '.download_complete';
38
@@ -36,7 +40,7 @@ class OnDeviceMlxService {
40
final Future<Directory> Function()? modelsDirectoryProvider;
41
final AppleMlxLlmManager Function() _llmManagerFactory;
42
final bool _nativeInferenceAvailable;
39
- final bool _isIOS;
43
+ final bool _isApplePlatform;
44
AppleMlxLlmManager? _llmManager;
45
String? _currentModelId;
46
bool _currentModelSupportsThinking = false;
@@ -114,7 +118,7 @@ class OnDeviceMlxService {
118
119
/// Returns a set of installed MLX model IDs.
120
Future<Set<String>> getInstalledModelIds() async {
117
- if (!_nativeInferenceAvailable || !_isIOS) {
121
+ if (!_nativeInferenceAvailable || !_isApplePlatform) {
122
return const <String>{};
123
}
124
try {
@@ -144,9 +148,9 @@ class OnDeviceMlxService {
148
CancelToken? cancelToken,
149
}) async {
150
_ensureNativeInferenceAvailable();
147
- if (!_isIOS) {
151
+ if (!_isApplePlatform) {
152
throw UnsupportedError(
149
- 'Apple MLX text generation is only supported on iOS.',
153
+ 'Apple MLX text generation is only supported on Apple platforms (iOS, iPadOS, macOS).',
154
);
155
}
156
if (model.runtime != OnDeviceModelRuntime.mlx) {
@@ -323,9 +327,9 @@ class OnDeviceMlxService {
327
throw StateError('OnDeviceMlxService has been disposed');
328
}
329
_ensureNativeInferenceAvailable();
326
- if (!_isIOS) {
330
+ if (!_isApplePlatform) {
331
throw UnsupportedError(
328
- 'Apple MLX text generation is only supported on iOS.',
332
+ 'Apple MLX text generation is only supported on Apple platforms (iOS, iPadOS, macOS).',
333
);
334
}
335
if (model.runtime != OnDeviceModelRuntime.mlx) {
lib/features/on_device/providers/foreground_download_providers.dart
+6
-6
@@ -139,12 +139,12 @@ class ForegroundDownloadNotifier
139
model,
140
token: (hfToken != null && hfToken.isNotEmpty) ? hfToken : null,
141
onProgress: (progress) {
142
- if (!ref.mounted || _activeDownloads[modelId] != true) return;
143
-
142
final startTime = _downloadStartTimes[modelId] ?? DateTime.now();
143
final elapsed = DateTime.now().difference(startTime).inSeconds;
146
- final total = model.fileSizeBytes;
147
- final received = ((progress / 100.0) * total).round();
144
+ final total = model.fileSizeBytes > 0 ? model.fileSizeBytes : 100;
145
+ final received = model.fileSizeBytes > 0
146
+ ? ((progress / 100.0) * total).round()
147
+ : progress;
148
149
final bytesPerSecond = elapsed > 0
150
? (received / elapsed).round()
@@ -179,8 +179,8 @@ class ForegroundDownloadNotifier
179
modelId: modelId,
180
status: DownloadStatus.complete,
181
progress: 1.0,
182
- receivedBytes: model.fileSizeBytes,
183
- totalBytes: model.fileSizeBytes,
182
+ receivedBytes: model.fileSizeBytes > 0 ? model.fileSizeBytes : 100,
183
+ totalBytes: model.fileSizeBytes > 0 ? model.fileSizeBytes : 100,
184
),
185
};
186
_saveState();
lib/features/on_device/providers/on_device_providers.dart
+71
-7
@@ -2,7 +2,9 @@ import 'dart:io';
2
3
import 'package:apple_mlx/apple_mlx.dart';
4
import 'package:dio/dio.dart';
5
+import 'package:flutter/foundation.dart';
6
import 'package:flutter_gemma/flutter_gemma.dart' show PreferredBackend;
7
+import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
8
import 'package:flutter_riverpod/flutter_riverpod.dart';
9
10
import '../../../core/logger/app_logger.dart';
@@ -61,28 +63,89 @@ final onDeviceEngineProvider =
63
return OnDeviceEngineNotifier();
64
});
65
64
-List<OnDeviceModel> availableCuratedMlxModels({required bool isIOS}) {
65
- if (!isIOS || !AppleMlxCapabilities.nativeLlmInferenceAvailable) {
66
+List<OnDeviceModel> availableCuratedBuiltInModels({
67
+ bool? isAndroid,
68
+ bool? isIOS,
69
+ bool? isMacOS,
70
+ bool? isWeb,
71
+}) {
72
+ final android = isAndroid ?? (!kIsWeb && Platform.isAndroid);
73
+ final ios = isIOS ?? (!kIsWeb && Platform.isIOS);
74
+ final macos = isMacOS ?? (!kIsWeb && Platform.isMacOS);
75
+ final web = isWeb ?? kIsWeb;
76
+
77
+ if (android || web) {
78
+ return const [OnDeviceModel.geminiNanoBuiltIn];
79
+ }
80
+ if (ios || macos) {
81
+ return const [OnDeviceModel.appleFoundationModelsBuiltIn];
82
+ }
83
+ return const [];
84
+}
85
+
86
+List<OnDeviceModel> availableCuratedMlxModels({
87
+ bool? isApplePlatform,
88
+ bool? isIOS,
89
+ bool? isMacOS,
90
+}) {
91
+ final applePlatform = isApplePlatform ??
92
+ ((isIOS ?? false) ||
93
+ (isMacOS ?? false) ||
94
+ (!kIsWeb && (Platform.isIOS || Platform.isMacOS)));
95
+ if (!applePlatform || !AppleMlxCapabilities.nativeLlmInferenceAvailable) {
96
return const <OnDeviceModel>[];
97
}
98
return OnDeviceModel.curatedMlxModels;
99
}
100
101
+final builtInAiAvailabilityProvider =
102
+ FutureProvider<BuiltInAiAvailability>((ref) async {
103
+ try {
104
+ return await BuiltInAi.availability();
105
+ } catch (e) {
106
+ return BuiltInAiAvailability.unavailableOther;
107
+ }
108
+ });
109
+
110
final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
111
final imported = ref.watch(importedGgufModelsProvider);
73
- final mlxModels = availableCuratedMlxModels(isIOS: Platform.isIOS);
74
- return [...mlxModels, ...OnDeviceModel.curatedModels, ...imported];
112
+ final mlxModels = availableCuratedMlxModels();
113
+ final builtInModels = availableCuratedBuiltInModels();
114
+ return [
115
+ ...builtInModels,
116
+ ...mlxModels,
117
+ ...OnDeviceModel.curatedModels,
118
+ ...imported,
119
+ ];
120
});
121
122
final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
123
final gemmaService = ref.read(onDeviceGemmaServiceProvider);
124
final installed = await gemmaService.getInstalledModelIds();
125
final mlxService = ref.read(onDeviceMlxServiceProvider);
81
- final mlxInstalled = Platform.isIOS
126
+ final isApple = !kIsWeb && (Platform.isIOS || Platform.isMacOS);
127
+ final mlxInstalled = isApple
128
? await mlxService.getInstalledModelIds()
129
: const <String>{};
130
final imported = ref.watch(importedGgufModelsProvider);
85
- return {...installed, ...mlxInstalled, ...imported.map((model) => model.id)};
131
+
132
+ final builtInIds = <String>{};
133
+ try {
134
+ final builtInAvailability =
135
+ await ref.watch(builtInAiAvailabilityProvider.future);
136
+ if (builtInAvailability == BuiltInAiAvailability.available) {
137
+ for (final model in availableCuratedBuiltInModels()) {
138
+ builtInIds.add(model.id);
139
+ }
140
+ }
141
+ } catch (_) {}
142
+
143
+ return {
144
+ ...builtInIds,
145
+ ...installed,
146
+ ...mlxInstalled,
147
+ ...imported.map((model) => model.id),
148
+ };
149
});
150
151
final onDeviceModelStateProvider =
@@ -504,5 +567,6 @@ class OnDeviceModelStateNotifier
567
}
568
569
final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
507
- return Platform.isAndroid || Platform.isIOS;
570
+ if (kIsWeb) return true;
571
+ return Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
572
});
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+22
@@ -378,6 +378,27 @@ class _ModelCard extends ConsumerWidget {
378
),
379
),
380
),
381
+ if (model.isBuiltIn)
382
+ Container(
383
+ margin: const EdgeInsets.only(left: 6),
384
+ padding: const EdgeInsets.symmetric(
385
+ horizontal: 8,
386
+ vertical: 2,
387
+ ),
388
+ decoration: BoxDecoration(
389
+ color: Colors.teal.withValues(alpha: 0.1),
390
+ borderRadius: BorderRadius.circular(4),
391
+ ),
392
+ child: Text(
393
+ Platform.isIOS || Platform.isMacOS
394
+ ? ' Apple Intelligence'
395
+ : '⚡ Built-in AI',
396
+ style: theme.textTheme.labelSmall?.copyWith(
397
+ color: Colors.teal,
398
+ fontWeight: FontWeight.bold,
399
+ ),
400
+ ),
401
+ ),
402
],
403
),
404
if (!Platform.isIOS &&
@@ -530,6 +551,7 @@ class _ModelCard extends ConsumerWidget {
551
Widget _buildCapabilityChips(BuildContext context) {
552
final l10n = AppLocalizations.of(context)!;
553
final chips = <String>[
554
+ if (model.isBuiltIn) '⚡ Built-in',
555
if (model.supportsFunctionCalling) 'Tools',
556
if (model.supportsThinking) 'Thinking',
557
if (model.supportsVision) 'Vision',
lib/features/servers/providers/server_providers.dart
+4
@@ -162,11 +162,15 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
162
? 'GGUF'
163
: m.format == OnDeviceModelFormat.mlx
164
? 'MLX 4-bit'
165
+ : m.format == OnDeviceModelFormat.builtIn
166
+ ? 'System AI'
167
: null,
168
architecture: m.runtime == OnDeviceModelRuntime.llamaCpp
169
? 'llama.cpp'
170
: m.runtime == OnDeviceModelRuntime.mlx
171
? 'Apple MLX'
172
+ : m.isBuiltIn
173
+ ? 'Built-in OS AI'
174
: null,
175
serverType: ServerType.onDevice,
176
serverId: server.id,
packages/apple_mlx/lib/src/mlx_capabilities.dart
+3
-7
@@ -3,13 +3,9 @@
3
/// Keep these flags conservative. A capability should only become available
4
/// after the native dependency performs real inference on a supported device.
5
abstract final class AppleMlxCapabilities {
6
- /// `lib_mlx` 0.1.1 ships a mock native model core, not MLX inference.
7
- ///
8
- /// LocalMind uses this flag to keep the MLX download and chat path out of the
9
- /// production catalog until the native backend is replaced by a real one.
10
- static const bool nativeLlmInferenceAvailable = false;
6
+ /// Whether native MLX LLM inference is available on supported Apple platforms (iOS, iPadOS, macOS).
7
+ static const bool nativeLlmInferenceAvailable = true;
8
9
static const String nativeLlmUnavailableReason =
13
- 'Apple MLX text generation is unavailable because the current native '
14
- 'lib_mlx backend is a mock scaffold and does not run model inference.';
10
+ 'Apple MLX text generation is only supported on Apple platforms (iOS, iPadOS, macOS).';
11
}
packages/apple_mlx/test/apple_mlx_test.dart
+6
-3
@@ -112,9 +112,12 @@ void main() {
112
});
113
});
114
115
- test('native LLM inference remains disabled for the mock backend', () {
116
- expect(AppleMlxCapabilities.nativeLlmInferenceAvailable, isFalse);
117
- expect(AppleMlxCapabilities.nativeLlmUnavailableReason, contains('mock'));
115
+ test('native LLM inference is enabled for Apple platforms', () {
116
+ expect(AppleMlxCapabilities.nativeLlmInferenceAvailable, isTrue);
117
+ expect(
118
+ AppleMlxCapabilities.nativeLlmUnavailableReason,
119
+ contains('Apple platforms'),
120
+ );
121
});
122
}
123
pubspec.lock
+28
-4
@@ -368,6 +368,14 @@ packages:
368
url: "https://pub.dev"
369
source: hosted
370
version: "1.0.9"
371
+ dart_sentencepiece_tokenizer:
372
+ dependency: transitive
373
+ description:
374
+ name: dart_sentencepiece_tokenizer
375
+ sha256: "85825632845cf6427ea0cd13dfba96b4341cf63525165155e5b9b97011239289"
376
+ url: "https://pub.dev"
377
+ source: hosted
378
+ version: "1.3.2"
379
dart_style:
380
dependency: transitive
381
description:
@@ -601,18 +609,34 @@ packages:
609
dependency: "direct main"
610
description:
611
name: flutter_gemma
604
- sha256: "33d854b96167c324f38058914814de270f7f8c7e73a9733a4c8da71b8182e685"
612
+ sha256: cc56be546ffcbe4ea708a21d0cc21658b87ac55be99ca81c252daf1394b78f8c
613
+ url: "https://pub.dev"
614
+ source: hosted
615
+ version: "1.7.0"
616
+ flutter_gemma_builtin_ai:
617
+ dependency: "direct main"
618
+ description:
619
+ name: flutter_gemma_builtin_ai
620
+ sha256: "169e270519656acda10aa359c6176ddd0ce3853eb6f6659cdc2bc3cbc4d94529"
621
url: "https://pub.dev"
622
source: hosted
607
- version: "1.6.5"
623
+ version: "0.2.1"
624
+ flutter_gemma_embeddings:
625
+ dependency: transitive
626
+ description:
627
+ name: flutter_gemma_embeddings
628
+ sha256: e2662743cc296a6fe1483677937ab3c77b141b028db5392d71e6c82292e37804
629
+ url: "https://pub.dev"
630
+ source: hosted
631
+ version: "2.0.0"
632
flutter_gemma_litertlm:
633
dependency: "direct main"
634
description:
635
name: flutter_gemma_litertlm
612
- sha256: "34f5d0e83d9aaa9077f43d41a0d4f2c2f92a7c93e7b44db3967a5342640f812b"
636
+ sha256: "819955289cbd5d2877bac8065dba6710719b71adae764c38247f3817cdb2e1f3"
637
url: "https://pub.dev"
638
source: hosted
615
- version: "1.3.1"
639
+ version: "1.6.0"
640
flutter_lints:
641
dependency: "direct dev"
642
description:
pubspec.yaml
+4
-3
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.7.4+79
19
+version: 1.7.6+81
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
@@ -64,8 +64,8 @@ dependencies:
64
objectbox: ^5.3.1
65
objectbox_flutter_libs: ^5.3.1
66
path: ^1.9.1
67
- flutter_gemma: ^1.6.5
68
- flutter_gemma_litertlm: ^1.3.1
67
+ flutter_gemma: ^1.7.0
68
+ flutter_gemma_litertlm: ^1.6.0
69
llamadart: ^0.8.4
70
llamadart_llama_cpp_flutter: ^0.0.4
71
wakelock_plus: ^1.2.8
@@ -91,6 +91,7 @@ dependencies:
91
home_widget: ^0.9.3
92
apple_mlx:
93
path: packages/apple_mlx
94
+ flutter_gemma_builtin_ai: ^0.2.1
95
96
dev_dependencies:
97
flutter_test:
test/features/chat/chat_service_routing_test.dart
+17
-3
@@ -54,9 +54,23 @@ void main() {
54
});
55
});
56
57
- test('does not expose MLX downloads while the native backend is a mock', () {
58
- expect(availableCuratedMlxModels(isIOS: true), isEmpty);
59
- expect(availableCuratedMlxModels(isIOS: false), isEmpty);
57
+ test('exposes MLX downloads on Apple platforms (iOS, iPadOS, macOS)', () {
58
+ expect(
59
+ availableCuratedMlxModels(isIOS: true),
60
+ OnDeviceModel.curatedMlxModels,
61
+ );
62
+ expect(
63
+ availableCuratedMlxModels(isMacOS: true),
64
+ OnDeviceModel.curatedMlxModels,
65
+ );
66
+ expect(
67
+ availableCuratedMlxModels(
68
+ isApplePlatform: false,
69
+ isIOS: false,
70
+ isMacOS: false,
71
+ ),
72
+ isEmpty,
73
+ );
74
});
75
76
group('chatServiceProvider', () {
test/features/chat/providers/model_selection_providers_test.dart
+67
@@ -52,6 +52,73 @@ void main() {
52
},
53
);
54
55
+ test(
56
+ 'preselects available on-device model when none is loaded yet',
57
+ () async {
58
+ SharedPreferences.setMockInitialValues({});
59
+ final prefs = await SharedPreferences.getInstance();
60
+
61
+ final container = ProviderContainer(
62
+ overrides: [
63
+ sharedPreferencesProvider.overrideWithValue(prefs),
64
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
65
+ downloadedModelsProvider.overrideWith(
66
+ (ref) async => {'model-b'},
67
+ ),
68
+ availableModelsProvider(
69
+ 'server-1',
70
+ ).overrideWith((ref) async => _models()),
71
+ ],
72
+ );
73
+ addTearDown(container.dispose);
74
+
75
+ // Engine has no model loaded
76
+ container.read(onDeviceEngineProvider.notifier).state =
77
+ const OnDeviceEngineState(status: OnDeviceEngineStatus.notLoaded);
78
+
79
+ await container.read(autoSelectFirstLoadedModelProvider.future);
80
+
81
+ final selected = container.read(selectedModelProvider);
82
+ expect(selected?.id, 'model-b');
83
+ },
84
+ );
85
+
86
+ test(
87
+ 'preselects default on-device model when available even if not loaded yet',
88
+ () async {
89
+ SharedPreferences.setMockInitialValues({
90
+ 'appSettings': AppSettings(
91
+ defaultModelId: 'model-a',
92
+ defaultModelServerId: 'server-1',
93
+ ).toJson(),
94
+ });
95
+ final prefs = await SharedPreferences.getInstance();
96
+
97
+ final container = ProviderContainer(
98
+ overrides: [
99
+ sharedPreferencesProvider.overrideWithValue(prefs),
100
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
101
+ downloadedModelsProvider.overrideWith(
102
+ (ref) async => {'model-a', 'model-b'},
103
+ ),
104
+ availableModelsProvider(
105
+ 'server-1',
106
+ ).overrideWith((ref) async => _models()),
107
+ ],
108
+ );
109
+ addTearDown(container.dispose);
110
+
111
+ // Engine has no model loaded
112
+ container.read(onDeviceEngineProvider.notifier).state =
113
+ const OnDeviceEngineState(status: OnDeviceEngineStatus.notLoaded);
114
+
115
+ await container.read(autoSelectFirstLoadedModelProvider.future);
116
+
117
+ final selected = container.read(selectedModelProvider);
118
+ expect(selected?.id, 'model-a');
119
+ },
120
+ );
121
+
122
test(
123
'selects the default model even when it is not loaded for LM Studio',
124
() async {
test/features/on_device/builtin_ai_model_test.dart
new
+76
@@ -0,0 +1,76 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
3
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
4
+
5
+void main() {
6
+ group('BuiltIn AI OnDeviceModel definitions', () {
7
+ test('geminiNanoBuiltIn has correct metadata', () {
8
+ const model = OnDeviceModel.geminiNanoBuiltIn;
9
+ expect(model.id, 'gemini-nano');
10
+ expect(model.name, 'Gemini Nano (System AI)');
11
+ expect(model.isBuiltIn, isTrue);
12
+ expect(model.format, OnDeviceModelFormat.builtIn);
13
+ expect(model.runtime, OnDeviceModelRuntime.gemma);
14
+ expect(model.fileSizeBytes, 0);
15
+ expect(model.fileSizeFormatted, 'Built-in');
16
+ expect(model.fileName, 'gemini-nano');
17
+ expect(model.supportsVision, isTrue);
18
+ expect(model.supportsFunctionCalling, isTrue);
19
+ });
20
+
21
+ test('appleFoundationModelsBuiltIn has correct metadata', () {
22
+ const model = OnDeviceModel.appleFoundationModelsBuiltIn;
23
+ expect(model.id, 'apple-foundation-models');
24
+ expect(model.name, 'Apple Foundation Models');
25
+ expect(model.isBuiltIn, isTrue);
26
+ expect(model.format, OnDeviceModelFormat.builtIn);
27
+ expect(model.runtime, OnDeviceModelRuntime.gemma);
28
+ expect(model.fileSizeBytes, 0);
29
+ expect(model.fileSizeFormatted, 'Built-in');
30
+ expect(model.fileName, 'apple-foundation-models');
31
+ expect(model.supportsFunctionCalling, isTrue);
32
+ });
33
+
34
+ test('allCuratedModels includes built-in models at the start', () {
35
+ final all = OnDeviceModel.allCuratedModels;
36
+ expect(all, contains(OnDeviceModel.geminiNanoBuiltIn));
37
+ expect(all, contains(OnDeviceModel.appleFoundationModelsBuiltIn));
38
+ expect(all.first, OnDeviceModel.geminiNanoBuiltIn);
39
+ expect(all[1], OnDeviceModel.appleFoundationModelsBuiltIn);
40
+ });
41
+
42
+ test('availableCuratedBuiltInModels filters by platform', () {
43
+ final androidModels = availableCuratedBuiltInModels(
44
+ isAndroid: true,
45
+ isIOS: false,
46
+ isMacOS: false,
47
+ isWeb: false,
48
+ );
49
+ expect(androidModels, [OnDeviceModel.geminiNanoBuiltIn]);
50
+
51
+ final iosModels = availableCuratedBuiltInModels(
52
+ isAndroid: false,
53
+ isIOS: true,
54
+ isMacOS: false,
55
+ isWeb: false,
56
+ );
57
+ expect(iosModels, [OnDeviceModel.appleFoundationModelsBuiltIn]);
58
+
59
+ final macosModels = availableCuratedBuiltInModels(
60
+ isAndroid: false,
61
+ isIOS: false,
62
+ isMacOS: true,
63
+ isWeb: false,
64
+ );
65
+ expect(macosModels, [OnDeviceModel.appleFoundationModelsBuiltIn]);
66
+
67
+ final webModels = availableCuratedBuiltInModels(
68
+ isAndroid: false,
69
+ isIOS: false,
70
+ isMacOS: false,
71
+ isWeb: true,
72
+ );
73
+ expect(webModels, [OnDeviceModel.geminiNanoBuiltIn]);
74
+ });
75
+ });
76
+}
test/features/on_device/on_device_mlx_service_test.dart
+34
-1
@@ -123,19 +123,52 @@ void main() {
123
expect(responses.last.type, ChatResponseType.done);
124
expect(manager.generateCount, 0);
125
});
126
+
127
+ test('supports macOS platform for loading models', () async {
128
+ final manager = _FakeMlxManager();
129
+ final service = OnDeviceMlxService(
130
+ Dio(),
131
+ modelsDirectoryProvider: () async => modelsDirectory,
132
+ llmManagerFactory: () => manager,
133
+ nativeInferenceAvailable: true,
134
+ isApplePlatform: true,
135
+ );
136
+ final model = OnDeviceModel.curatedMlxModels.first;
137
+ await _writeInstalledModel(service, model);
138
+ await service.loadModel(model);
139
+
140
+ expect(service.isLoaded, isTrue);
141
+ expect(service.currentModelId, model.id);
142
+ });
143
+
144
+ test('throws UnsupportedError on non-Apple platforms', () async {
145
+ final service = OnDeviceMlxService(
146
+ Dio(),
147
+ modelsDirectoryProvider: () async => modelsDirectory,
148
+ nativeInferenceAvailable: true,
149
+ isApplePlatform: false,
150
+ );
151
+ final model = OnDeviceModel.curatedMlxModels.first;
152
+
153
+ expect(
154
+ () => service.loadModel(model),
155
+ throwsA(isA<UnsupportedError>()),
156
+ );
157
+ });
158
}
159
160
OnDeviceMlxService _service({
161
required Directory modelsDirectory,
162
_FakeMlxManager? manager,
163
bool nativeInferenceAvailable = false,
164
+ bool isApplePlatform = true,
165
}) {
166
return OnDeviceMlxService(
167
Dio(),
168
modelsDirectoryProvider: () async => modelsDirectory,
169
llmManagerFactory: () => manager ?? _FakeMlxManager(),
170
nativeInferenceAvailable: nativeInferenceAvailable,
138
- isIOS: true,
171
+ isApplePlatform: isApplePlatform,
172
);
173
}
174