@tduncan / Cortana / commits / a22be8a

feat: enable built-in system AI model support for Android and Apple platforms

abdulmominsakib committed Aug 31, 2026 at 17:33 UTC a22be8aa582b1e1b5dc9db9367c6840533793d67
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