@tduncan / Cortana / commits / aff592d

feat: implement apple_mlx package and integrate OnDeviceMlxService into chat providers

abdulmominsakib committed Aug 26, 2026 at 15:15 UTC aff592df2dff2120eb3b5f3f1ce179010b0c38cf
28 files changed +1622 -103
lib/features/chat/providers/chat_notifier.dart
+1
@@ -1711,6 +1711,7 @@ class ChatNotifier extends Notifier<ChatState> {
1711 dio: ref.read(dioProvider),
1712 onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
1713 onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
1714 + onDeviceMlxService: ref.read(onDeviceMlxServiceProvider),
1715 loadedOnDeviceRuntime: ref.read(
1716 onDeviceEngineProvider.select((s) => s.loadedRuntime),
1717 ),
lib/features/chat/providers/chat_service_providers.dart
+7
@@ -11,6 +11,7 @@ import 'package:localmind/features/on_device/data/on_device_chat_service.dart';
11 import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
12 import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
13 import 'package:localmind/features/on_device/data/models/on_device_model.dart';
14 +import 'package:localmind/features/on_device/data/on_device_mlx_service.dart';
15 import '../data/chat_service.dart';
16 import '../data/smart_reply_service.dart';
17 import '../data/title_generation_service.dart';
@@ -33,6 +34,7 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
34 dio: ref.read(dioProvider),
35 onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
36 onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
37 + onDeviceMlxService: ref.read(onDeviceMlxServiceProvider),
38 loadedOnDeviceRuntime: loadedRuntime,
39 imageCompressionEnabled: settings.$1,
40 imageCompressionLevel: settings.$2,
@@ -53,6 +55,7 @@ ChatService createChatServiceForServer({
55 required Dio dio,
56 required OnDeviceGemmaService onDeviceGemmaService,
57 required OnDeviceLlamaService onDeviceLlamaService,
58 + OnDeviceMlxService? onDeviceMlxService,
59 OnDeviceModelRuntime? loadedOnDeviceRuntime,
60 bool imageCompressionEnabled = true,
61 ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
@@ -61,6 +64,10 @@ ChatService createChatServiceForServer({
64 if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
65 return OnDeviceLlamaChatService(onDeviceLlamaService);
66 }
67 + if (loadedOnDeviceRuntime == OnDeviceModelRuntime.mlx &&
68 + onDeviceMlxService != null) {
69 + return OnDeviceMlxChatService(onDeviceMlxService);
70 + }
71
72 return ChatService.forServer(
73 server.type,
lib/features/on_device/components/model_card.dart
+22 -1
@@ -166,6 +166,22 @@ class _ModelCardHeader extends StatelessWidget {
166 ),
167 ),
168 ),
169 + if (model.isMlx)
170 + Container(
171 + margin: const EdgeInsets.only(left: 6),
172 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
173 + decoration: BoxDecoration(
174 + color: Colors.purple.withValues(alpha: 0.1),
175 + borderRadius: BorderRadius.circular(4),
176 + ),
177 + child: Text(
178 + ' Apple MLX',
179 + style: theme.textTheme.labelSmall?.copyWith(
180 + color: Colors.purple,
181 + fontWeight: FontWeight.bold,
182 + ),
183 + ),
184 + ),
185 ],
186 );
187 }
@@ -218,12 +234,14 @@ class _CapabilityChips extends StatelessWidget {
234 final chips = <String>[
235 if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
236 if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
237 + if (model.runtime == OnDeviceModelRuntime.mlx) ' Metal GPU',
238 if (model.supportsFunctionCalling) 'Tools',
239 if (model.supportsThinking) 'Thinking',
240 if (model.supportsVision) 'Vision',
241 model.languagesLabel,
242 if (model.backendNote != null &&
226 - model.backendNote!.toLowerCase() != 'llama.cpp')
243 + model.backendNote!.toLowerCase() != 'llama.cpp' &&
244 + model.backendNote!.toLowerCase() != 'apple mlx')
245 model.backendNote!,
246 if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
247 ];
@@ -519,6 +537,9 @@ class _DownloadedActions extends ConsumerWidget {
537 await ref
538 .read(importedGgufModelsProvider.notifier)
539 .deleteModel(model.id);
540 + } else if (model.runtime == OnDeviceModelRuntime.mlx) {
541 + final mlxService = ref.read(onDeviceMlxServiceProvider);
542 + await mlxService.deleteModel(model.id);
543 } else {
544 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
545 await gemmaService.deleteModel(model.id);
lib/features/on_device/components/on_device_picker_section.dart
+24 -2
@@ -327,6 +327,26 @@ class _OnDeviceModelTile extends ConsumerWidget {
327 ),
328 ),
329 ),
330 + if (model.isMlx)
331 + Container(
332 + margin: const EdgeInsets.only(left: 6),
333 + padding: const EdgeInsets.symmetric(
334 + horizontal: 6,
335 + vertical: 2,
336 + ),
337 + decoration: BoxDecoration(
338 + color: Colors.purple.withValues(alpha: 0.1),
339 + borderRadius: BorderRadius.circular(4),
340 + ),
341 + child: const Text(
342 + ' MLX',
343 + style: TextStyle(
344 + fontSize: 9,
345 + fontWeight: FontWeight.bold,
346 + color: Colors.purple,
347 + ),
348 + ),
349 + ),
350 ],
351 ),
352 if (!Platform.isIOS &&
@@ -384,9 +404,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
404 ],
405 ),
406 ),
387 - const SizedBox(height: 4),
407 + const SizedBox(height: 6),
408 Wrap(
389 - spacing: 6,
409 + spacing: 4,
410 runSpacing: 4,
411 children: [
412 MetadataChip(
@@ -400,6 +420,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
420 ),
421 if (model.runtime == OnDeviceModelRuntime.llamaCpp)
422 MetadataChip(label: 'llama.cpp', isDark: isDark),
423 + if (model.runtime == OnDeviceModelRuntime.mlx)
424 + MetadataChip(label: ' Apple MLX', isDark: isDark),
425 if (!model.isImported)
426 MetadataChip(label: model.license, isDark: isDark),
427 if (!model.isImported)
lib/features/on_device/data/models/on_device_model.dart
+77 -2
@@ -2,9 +2,9 @@ import 'package:flutter_gemma/flutter_gemma.dart';
2
3 import '../../../../core/models/enums.dart';
4
5 -enum OnDeviceModelRuntime { gemma, llamaCpp }
5 +enum OnDeviceModelRuntime { gemma, llamaCpp, mlx }
6
7 -enum OnDeviceModelFormat { litertlm, task, binary, gguf }
7 +enum OnDeviceModelFormat { litertlm, task, binary, gguf, mlx }
8
9 enum OnDeviceImportedSource { localFile, huggingFace }
10
@@ -76,6 +76,7 @@ class OnDeviceModel {
76 }
77
78 bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
79 + bool get isMlx => runtime == OnDeviceModelRuntime.mlx;
80
81 bool get isImportedFromLocalFile =>
82 importedSource == OnDeviceImportedSource.localFile;
@@ -311,6 +312,80 @@ class OnDeviceModel {
312 isCpuOnly: true,
313 ),
314 ];
315 +
316 + static const List<OnDeviceModel> curatedMlxModels = [
317 + OnDeviceModel(
318 + id: 'mlx-qwen-2.5-0.5b',
319 + name: 'Qwen 2.5 0.5B (MLX)',
320 + huggingFaceUrl:
321 + 'https://huggingface.co/mlx-community/Qwen2.5-0.5B-Instruct-4bit',
322 + fileSizeBytes: 408944640,
323 + license: 'Apache-2.0',
324 + description:
325 + 'High efficiency compact Qwen 2.5 4-bit model running via Apple MLX.',
326 + isRecommended: true,
327 + minRamMb: 1536,
328 + parameterLabel: '0.5B (4-bit)',
329 + bestFor: 'Compact multilingual chat with tiny footprint',
330 + languagesLabel: 'Multilingual',
331 + backendNote: 'Apple MLX',
332 + runtime: OnDeviceModelRuntime.mlx,
333 + format: OnDeviceModelFormat.mlx,
334 + ),
335 + OnDeviceModel(
336 + id: 'mlx-llama-3.2-1b',
337 + name: 'Llama 3.2 1B (MLX)',
338 + huggingFaceUrl:
339 + 'https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit',
340 + fileSizeBytes: 786432000,
341 + license: 'Llama-3.2',
342 + description:
343 + 'Compact, ultra-fast Llama 3.2 4-bit model powered by Apple MLX Metal kernels.',
344 + isRecommended: true,
345 + minRamMb: 2048,
346 + parameterLabel: '1B (4-bit)',
347 + bestFor: 'Lightweight high-speed text generation',
348 + supportsThinking: false,
349 + languagesLabel: 'Multilingual',
350 + backendNote: 'Apple MLX',
351 + runtime: OnDeviceModelRuntime.mlx,
352 + format: OnDeviceModelFormat.mlx,
353 + ),
354 + OnDeviceModel(
355 + id: 'mlx-gemma-2-2b',
356 + name: 'Gemma 2 2B (MLX)',
357 + huggingFaceUrl: 'https://huggingface.co/mlx-community/gemma-2-2b-it-4bit',
358 + fileSizeBytes: 1530920960,
359 + license: 'Gemma',
360 + description:
361 + 'Native Apple MLX 4-bit model running with Metal GPU acceleration.',
362 + minRamMb: 4096,
363 + parameterLabel: '2B (4-bit)',
364 + bestFor: 'Fast chat on Apple Silicon',
365 + supportsThinking: false,
366 + languagesLabel: 'Multilingual',
367 + backendNote: 'Apple MLX',
368 + runtime: OnDeviceModelRuntime.mlx,
369 + format: OnDeviceModelFormat.mlx,
370 + ),
371 + OnDeviceModel(
372 + id: 'mlx-smollm2-135m',
373 + name: 'SmolLM2 135M (MLX)',
374 + huggingFaceUrl:
375 + 'https://huggingface.co/mlx-community/SmolLM2-135M-Instruct',
376 + fileSizeBytes: 272629760,
377 + license: 'Apache-2.0',
378 + description:
379 + 'Ultra-compact 135M MLX model for near-instant on-device inference.',
380 + minRamMb: 1024,
381 + parameterLabel: '135M',
382 + bestFor: 'Instant local inference and memory efficiency',
383 + languagesLabel: 'English',
384 + backendNote: 'Apple MLX',
385 + runtime: OnDeviceModelRuntime.mlx,
386 + format: OnDeviceModelFormat.mlx,
387 + ),
388 + ];
389 }
390
391 class DownloadedModel {
lib/features/on_device/data/on_device_mlx_service.dart new
+415
@@ -0,0 +1,415 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import 'package:apple_mlx/apple_mlx.dart';
5 +import 'package:dio/dio.dart';
6 +import 'package:path/path.dart' as p;
7 +import 'package:path_provider/path_provider.dart';
8 +
9 +import '../../../core/logger/app_logger.dart';
10 +import '../../../core/models/enums.dart';
11 +import '../../servers/data/models/server.dart';
12 +import '../../chat/data/models/chat_parameters.dart';
13 +import '../../chat/data/models/message.dart';
14 +import '../../chat/data/models/mcp_integration.dart';
15 +import '../../chat/data/tools/tool_definition.dart';
16 +import '../../chat/data/chat_service.dart';
17 +import 'models/on_device_model.dart';
18 +
19 +class OnDeviceMlxService {
20 + OnDeviceMlxService(this._dio);
21 +
22 + final Dio _dio;
23 + AppleMlxLlmManager? _llmManager;
24 + String? _currentModelId;
25 + bool _isDisposed = false;
26 + final Map<String, CancelToken> _activeDownloads = {};
27 +
28 + String? get currentModelId => _currentModelId;
29 + bool get isLoaded => _llmManager?.isModelLoaded == true && !_isDisposed;
30 +
31 + /// Returns the base directory where MLX models are stored.
32 + Future<Directory> get _modelsDirectory async {
33 + final appDir = await getApplicationDocumentsDirectory();
34 + final mlxDir = Directory(p.join(appDir.path, 'models', 'mlx'));
35 + if (!await mlxDir.exists()) {
36 + await mlxDir.create(recursive: true);
37 + }
38 + return mlxDir;
39 + }
40 +
41 + /// Returns the specific directory for a model.
42 + Future<Directory> getModelDirectory(String modelId) async {
43 + final base = await _modelsDirectory;
44 + final sanitized = modelId.replaceAll('/', '_');
45 + return Directory(p.join(base.path, sanitized));
46 + }
47 +
48 + /// Checks if a model is fully downloaded and installed.
49 + Future<bool> isModelInstalled(String modelId) async {
50 + try {
51 + final dir = await getModelDirectory(modelId);
52 + if (!await dir.exists()) return false;
53 +
54 + final config = File(p.join(dir.path, 'config.json'));
55 + if (!await config.exists()) return false;
56 +
57 + final files = await dir.list().toList();
58 + final hasWeights = files.any(
59 + (f) =>
60 + f.path.endsWith('.safetensors') ||
61 + f.path.endsWith('.npz') ||
62 + f.path.endsWith('.bin'),
63 + );
64 + return hasWeights;
65 + } catch (e) {
66 + Log.error('Error checking MLX model installation for $modelId: $e');
67 + return false;
68 + }
69 + }
70 +
71 + /// Returns a set of installed MLX model IDs.
72 + Future<Set<String>> getInstalledModelIds() async {
73 + if (!Platform.isIOS && !Platform.isMacOS) {
74 + return const <String>{};
75 + }
76 + try {
77 + final base = await _modelsDirectory;
78 + if (!await base.exists()) return const <String>{};
79 +
80 + final installed = <String>{};
81 + final curated = OnDeviceModel.curatedMlxModels;
82 +
83 + for (final model in curated) {
84 + if (await isModelInstalled(model.id)) {
85 + installed.add(model.id);
86 + }
87 + }
88 + return installed;
89 + } catch (e) {
90 + Log.error('Error listing installed MLX models: $e');
91 + return const <String>{};
92 + }
93 + }
94 +
95 + /// Downloads all required files of an MLX model from Hugging Face.
96 + Future<void> downloadModel(
97 + OnDeviceModel model, {
98 + String? huggingFaceToken,
99 + void Function(int receivedBytes, int totalBytes)? onProgress,
100 + CancelToken? cancelToken,
101 + }) async {
102 + if (!Platform.isIOS && !Platform.isMacOS) {
103 + throw UnsupportedError(
104 + 'Apple MLX models are only supported on Apple Silicon (iOS/macOS).',
105 + );
106 + }
107 +
108 + final targetDir = await getModelDirectory(model.id);
109 + if (!await targetDir.exists()) {
110 + await targetDir.create(recursive: true);
111 + }
112 +
113 + // Extract repo ID from huggingFaceUrl if needed
114 + final repoId = _extractRepoId(model.huggingFaceUrl);
115 + final effectiveCancelToken = cancelToken ?? CancelToken();
116 + _activeDownloads[model.id] = effectiveCancelToken;
117 +
118 + try {
119 + Log.info('Fetching MLX model file list for $repoId from Hugging Face...');
120 +
121 + final headers = <String, dynamic>{
122 + if (huggingFaceToken != null && huggingFaceToken.isNotEmpty)
123 + 'Authorization': 'Bearer $huggingFaceToken',
124 + };
125 +
126 + // 1. Fetch file list from Hugging Face API
127 + final apiUrl = 'https://huggingface.co/api/models/$repoId/tree/main';
128 + List<String> filesToDownload = [];
129 + int totalModelBytes = model.fileSizeBytes;
130 +
131 + try {
132 + final res = await _dio.get<List<dynamic>>(
133 + apiUrl,
134 + options: Options(headers: headers),
135 + cancelToken: effectiveCancelToken,
136 + );
137 + if (res.data != null) {
138 + int computedTotal = 0;
139 + for (final item in res.data!) {
140 + if (item is Map) {
141 + final path = item['path'] as String?;
142 + final type = item['type'] as String?;
143 + final size = (item['size'] as num?)?.toInt() ?? 0;
144 + if (type == 'file' && path != null && !_shouldSkipFile(path)) {
145 + filesToDownload.add(path);
146 + computedTotal += size;
147 + }
148 + }
149 + }
150 + if (computedTotal > 0) {
151 + totalModelBytes = computedTotal;
152 + }
153 + }
154 + } catch (e) {
155 + Log.warning(
156 + 'Could not fetch Hugging Face file tree ($e). Using default MLX file list.',
157 + );
158 + filesToDownload = [
159 + 'config.json',
160 + 'model.safetensors',
161 + 'tokenizer.json',
162 + 'tokenizer_config.json',
163 + 'special_tokens_map.json',
164 + ];
165 + }
166 +
167 + if (filesToDownload.isEmpty) {
168 + filesToDownload = [
169 + 'config.json',
170 + 'model.safetensors',
171 + 'tokenizer.json',
172 + 'tokenizer_config.json',
173 + ];
174 + }
175 +
176 + int cumulativeDownloaded = 0;
177 + final fileSizes = <String, int>{};
178 +
179 + for (final fileName in filesToDownload) {
180 + if (effectiveCancelToken.isCancelled) break;
181 +
182 + final fileUrl = 'https://huggingface.co/$repoId/resolve/main/$fileName';
183 + final savePath = p.join(targetDir.path, fileName);
184 + int fileDownloadedBefore = fileSizes[fileName] ?? 0;
185 +
186 + await _dio.download(
187 + fileUrl,
188 + savePath,
189 + options: Options(headers: headers),
190 + cancelToken: effectiveCancelToken,
191 + onReceiveProgress: (received, total) {
192 + final delta = received - fileDownloadedBefore;
193 + fileDownloadedBefore = received;
194 + fileSizes[fileName] = received;
195 + cumulativeDownloaded += delta;
196 +
197 + onProgress?.call(
198 + cumulativeDownloaded,
199 + totalModelBytes > 0 ? totalModelBytes : cumulativeDownloaded,
200 + );
201 + },
202 + );
203 + }
204 +
205 + Log.info(
206 + 'MLX model ${model.id} successfully downloaded to ${targetDir.path}',
207 + );
208 + } catch (e) {
209 + Log.error('Failed to download MLX model ${model.id}: $e');
210 + rethrow;
211 + } finally {
212 + _activeDownloads.remove(model.id);
213 + }
214 + }
215 +
216 + /// Cancels an in-flight download.
217 + void cancelDownload(String modelId) {
218 + _activeDownloads[modelId]?.cancel('Download cancelled by user');
219 + _activeDownloads.remove(modelId);
220 + }
221 +
222 + /// Deletes a downloaded model from disk.
223 + Future<void> deleteModel(String modelId) async {
224 + try {
225 + if (_currentModelId == modelId) {
226 + await unloadModel();
227 + }
228 + final dir = await getModelDirectory(modelId);
229 + if (await dir.exists()) {
230 + await dir.delete(recursive: true);
231 + }
232 + Log.info('Deleted MLX model $modelId');
233 + } catch (e) {
234 + Log.error('Error deleting MLX model $modelId: $e');
235 + }
236 + }
237 +
238 + /// Loads an MLX model for inference.
239 + Future<void> loadModel(OnDeviceModel model) async {
240 + if (_isDisposed) {
241 + throw StateError('OnDeviceMlxService has been disposed');
242 + }
243 +
244 + final dir = await getModelDirectory(model.id);
245 + if (!await isModelInstalled(model.id)) {
246 + throw StateError(
247 + 'MLX Model ${model.id} is not downloaded. Please download it first.',
248 + );
249 + }
250 +
251 + await unloadModel();
252 +
253 + Log.info('Loading MLX model ${model.id} from ${dir.path}');
254 + final manager = AppleMlxLlmManager();
255 + await manager.loadAndStartServer(
256 + modelPath: dir.path,
257 + modelId: _extractRepoId(model.huggingFaceUrl),
258 + thinkingEnabled: model.supportsThinking,
259 + );
260 +
261 + _llmManager = manager;
262 + _currentModelId = model.id;
263 + Log.info('MLX model ${model.id} loaded successfully');
264 + }
265 +
266 + /// Unloads the active MLX model.
267 + Future<void> unloadModel() async {
268 + if (_llmManager != null) {
269 + Log.info('Unloading MLX model $_currentModelId');
270 + await _llmManager!.dispose();
271 + _llmManager = null;
272 + _currentModelId = null;
273 + }
274 + }
275 +
276 + /// Streams chat responses from the loaded MLX model.
277 + Stream<ChatResponse> sendMessage({
278 + required String modelId,
279 + required List<Message> messages,
280 + required ChatParameters params,
281 + }) async* {
282 + final manager = _llmManager;
283 + if (manager == null || !manager.isModelLoaded) {
284 + yield const ChatResponse(
285 + type: ChatResponseType.error,
286 + content: 'MLX model is not loaded. Please load the model first.',
287 + );
288 + yield const ChatResponse(type: ChatResponseType.done);
289 + return;
290 + }
291 +
292 + final formattedMessages = <Map<String, String>>[];
293 +
294 + if (params.systemPrompt != null && params.systemPrompt!.isNotEmpty) {
295 + formattedMessages.add({
296 + 'role': 'system',
297 + 'content': params.systemPrompt!,
298 + });
299 + }
300 +
301 + for (final m in messages) {
302 + final role = m.role == MessageRole.user
303 + ? 'user'
304 + : m.role == MessageRole.assistant
305 + ? 'assistant'
306 + : 'system';
307 + formattedMessages.add({'role': role, 'content': m.content});
308 + }
309 +
310 + try {
311 + final stream = manager.generateStream(
312 + messages: formattedMessages,
313 + temperature: params.temperature,
314 + maxTokens: params.maxTokens,
315 + thinking: true,
316 + );
317 +
318 + await for (final delta in stream) {
319 + if (delta.isDone) {
320 + yield const ChatResponse(type: ChatResponseType.done);
321 + break;
322 + }
323 +
324 + if (delta.reasoningContent != null &&
325 + delta.reasoningContent!.isNotEmpty) {
326 + yield ChatResponse(
327 + type: ChatResponseType.reasoning,
328 + content: delta.reasoningContent!,
329 + );
330 + }
331 +
332 + if (delta.content != null && delta.content!.isNotEmpty) {
333 + yield ChatResponse(
334 + type: ChatResponseType.message,
335 + content: delta.content!,
336 + );
337 + }
338 + }
339 + } catch (e) {
340 + Log.error('MLX generation error: $e');
341 + yield ChatResponse(
342 + type: ChatResponseType.error,
343 + content: 'MLX generation error: $e',
344 + );
345 + yield const ChatResponse(type: ChatResponseType.done);
346 + }
347 + }
348 +
349 + void dispose() {
350 + _isDisposed = true;
351 + for (final token in _activeDownloads.values) {
352 + token.cancel('Service disposed');
353 + }
354 + _activeDownloads.clear();
355 + unawaited(unloadModel());
356 + }
357 +
358 + String _extractRepoId(String url) {
359 + if (!url.startsWith('http')) return url;
360 + final uri = Uri.tryParse(url);
361 + if (uri == null) return url;
362 + final segments = uri.pathSegments;
363 + if (segments.length >= 2) {
364 + return '${segments[0]}/${segments[1]}';
365 + }
366 + return url;
367 + }
368 +
369 + bool _shouldSkipFile(String path) {
370 + final lower = path.toLowerCase();
371 + return lower.startsWith('.') ||
372 + lower.endsWith('.md') ||
373 + lower.endsWith('.gitattributes') ||
374 + lower.endsWith('.png') ||
375 + lower.endsWith('.jpg') ||
376 + lower.endsWith('.jpeg');
377 + }
378 +}
379 +
380 +class OnDeviceMlxChatService implements ChatService {
381 + OnDeviceMlxChatService(this._mlxService);
382 +
383 + final OnDeviceMlxService _mlxService;
384 + bool _isCancelled = false;
385 +
386 + @override
387 + Stream<ChatResponse> sendMessage({
388 + required Server server,
389 + required String modelId,
390 + required List<Message> messages,
391 + required ChatParameters params,
392 + List<McpIntegration>? integrations,
393 + List<ToolDefinition>? tools,
394 + bool continueGeneration = false,
395 + }) async* {
396 + _isCancelled = false;
397 + await for (final response in _mlxService.sendMessage(
398 + modelId: modelId,
399 + messages: messages,
400 + params: params,
401 + )) {
402 + if (_isCancelled) break;
403 + yield response;
404 + if (response.type == ChatResponseType.done ||
405 + response.type == ChatResponseType.error) {
406 + break;
407 + }
408 + }
409 + }
410 +
411 + @override
412 + void cancelStream() {
413 + _isCancelled = true;
414 + }
415 +}
lib/features/on_device/providers/foreground_download_providers.dart
+74 -31
@@ -9,6 +9,7 @@ import '../../../core/providers/review_prompt_providers.dart';
9 import '../../../core/providers/storage_providers.dart';
10 import '../data/models/download_progress_info.dart';
11 import '../data/models/download_status.dart';
12 +import '../data/models/on_device_model.dart';
13 import 'on_device_providers.dart';
14
15 final foregroundDownloadNotifierProvider =
@@ -92,42 +93,82 @@ class ForegroundDownloadNotifier
93 _saveState();
94
95 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
96 + final mlxService = ref.read(onDeviceMlxServiceProvider);
97 final hfToken = ref.read(
98 settingsProvider.select((s) => s.huggingFaceToken),
99 );
100
101 try {
100 - await gemmaService.installModel(
101 - model,
102 - token: (hfToken != null && hfToken.isNotEmpty) ? hfToken : null,
103 - onProgress: (progress) {
104 - if (!ref.mounted || _activeDownloads[modelId] != true) return;
105 -
106 - final startTime = _downloadStartTimes[modelId] ?? DateTime.now();
107 - final elapsed = DateTime.now().difference(startTime).inSeconds;
108 - final total = model.fileSizeBytes;
109 - final received = ((progress / 100.0) * total).round();
110 -
111 - final bytesPerSecond = elapsed > 0 ? (received / elapsed).round() : 0;
112 - final etaSeconds = bytesPerSecond > 0
113 - ? ((total - received) / bytesPerSecond).round()
114 - : null;
115 -
116 - state = {
117 - ...state,
118 - modelId: DownloadProgressInfo(
119 - modelId: modelId,
120 - status: DownloadStatus.running,
121 - progress: progress / 100.0,
122 - receivedBytes: received,
123 - totalBytes: total,
124 - bytesPerSecond: bytesPerSecond,
125 - etaSeconds: etaSeconds,
126 - ),
127 - };
128 - _saveState();
129 - },
130 - );
102 + if (model.runtime == OnDeviceModelRuntime.mlx) {
103 + await mlxService.downloadModel(
104 + model,
105 + huggingFaceToken: (hfToken != null && hfToken.isNotEmpty)
106 + ? hfToken
107 + : null,
108 + onProgress: (received, total) {
109 + if (!ref.mounted || _activeDownloads[modelId] != true) return;
110 +
111 + final startTime = _downloadStartTimes[modelId] ?? DateTime.now();
112 + final elapsed = DateTime.now().difference(startTime).inSeconds;
113 + final bytesPerSecond = elapsed > 0
114 + ? (received / elapsed).round()
115 + : 0;
116 + final etaSeconds = bytesPerSecond > 0
117 + ? ((total - received) / bytesPerSecond).round()
118 + : null;
119 +
120 + final progress = total > 0 ? (received / total) : 0.0;
121 +
122 + state = {
123 + ...state,
124 + modelId: DownloadProgressInfo(
125 + modelId: modelId,
126 + status: DownloadStatus.running,
127 + progress: progress.clamp(0.0, 1.0),
128 + receivedBytes: received,
129 + totalBytes: total,
130 + bytesPerSecond: bytesPerSecond,
131 + etaSeconds: etaSeconds,
132 + ),
133 + };
134 + _saveState();
135 + },
136 + );
137 + } else {
138 + await gemmaService.installModel(
139 + model,
140 + token: (hfToken != null && hfToken.isNotEmpty) ? hfToken : null,
141 + onProgress: (progress) {
142 + if (!ref.mounted || _activeDownloads[modelId] != true) return;
143 +
144 + final startTime = _downloadStartTimes[modelId] ?? DateTime.now();
145 + final elapsed = DateTime.now().difference(startTime).inSeconds;
146 + final total = model.fileSizeBytes;
147 + final received = ((progress / 100.0) * total).round();
148 +
149 + final bytesPerSecond = elapsed > 0
150 + ? (received / elapsed).round()
151 + : 0;
152 + final etaSeconds = bytesPerSecond > 0
153 + ? ((total - received) / bytesPerSecond).round()
154 + : null;
155 +
156 + state = {
157 + ...state,
158 + modelId: DownloadProgressInfo(
159 + modelId: modelId,
160 + status: DownloadStatus.running,
161 + progress: progress / 100.0,
162 + receivedBytes: received,
163 + totalBytes: total,
164 + bytesPerSecond: bytesPerSecond,
165 + etaSeconds: etaSeconds,
166 + ),
167 + };
168 + _saveState();
169 + },
170 + );
171 + }
172
173 // Download completed
174 if (_activeDownloads[modelId] == true) {
@@ -187,6 +228,8 @@ class ForegroundDownloadNotifier
228 }
229
230 Future<void> cancelDownload(String modelId) async {
231 + final mlxService = ref.read(onDeviceMlxServiceProvider);
232 + mlxService.cancelDownload(modelId);
233 _activeDownloads.remove(modelId);
234 _downloadStartTimes.remove(modelId);
235 state = {...state}..remove(modelId);
lib/features/on_device/providers/on_device_providers.dart
+54 -6
@@ -16,6 +16,7 @@ import '../data/models/on_device_model.dart';
16 import '../data/notification_permission_service.dart';
17 import '../data/on_device_gemma_service.dart';
18 import '../data/on_device_llama_service.dart';
19 +import '../data/on_device_mlx_service.dart';
20 import '../../servers/providers/server_providers.dart';
21
22 final notificationPermissionServiceProvider =
@@ -35,6 +36,12 @@ final onDeviceLlamaServiceProvider = Provider<OnDeviceLlamaService>((ref) {
36 return service;
37 });
38
39 +final onDeviceMlxServiceProvider = Provider<OnDeviceMlxService>((ref) {
40 + final service = OnDeviceMlxService(ref.read(dioProvider));
41 + ref.onDispose(() => service.dispose());
42 + return service;
43 +});
44 +
45 final importedGgufModelRepositoryProvider =
46 Provider<ImportedGgufModelRepository>((ref) {
47 return ImportedGgufModelRepository(
@@ -55,14 +62,21 @@ final onDeviceEngineProvider =
62
63 final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
64 final imported = ref.watch(importedGgufModelsProvider);
58 - return [...OnDeviceModel.curatedModels, ...imported];
65 + final mlxModels = Platform.isIOS
66 + ? OnDeviceModel.curatedMlxModels
67 + : const <OnDeviceModel>[];
68 + return [...mlxModels, ...OnDeviceModel.curatedModels, ...imported];
69 });
70
71 final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
72 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
73 final installed = await gemmaService.getInstalledModelIds();
74 + final mlxService = ref.read(onDeviceMlxServiceProvider);
75 + final mlxInstalled = Platform.isIOS
76 + ? await mlxService.getInstalledModelIds()
77 + : const <String>{};
78 final imported = ref.watch(importedGgufModelsProvider);
65 - return {...installed, ...imported.map((model) => model.id)};
79 + return {...installed, ...mlxInstalled, ...imported.map((model) => model.id)};
80 });
81
82 final onDeviceModelStateProvider =
@@ -239,6 +253,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
253 ref.read(onDeviceGemmaServiceProvider);
254 OnDeviceLlamaService get _llamaService =>
255 ref.read(onDeviceLlamaServiceProvider);
256 + OnDeviceMlxService get _mlxService => ref.read(onDeviceMlxServiceProvider);
257
258 Future<void> loadModel(String modelId, PreferredBackend backend) async {
259 if (!ref.mounted) return;
@@ -287,6 +302,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
302
303 final settings = ref.read(settingsProvider);
304 await _gemmaService.unloadModel();
305 + await _mlxService.unloadModel();
306 if (!ref.mounted) return;
307 await _llamaService.loadModel(
308 model,
@@ -294,6 +310,24 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
310 useGpu: !model.isCpuOnly && effectiveBackend == PreferredBackend.gpu,
311 );
312 if (!ref.mounted) return;
313 + } else if (model.runtime == OnDeviceModelRuntime.mlx) {
314 + final isInstalled = await _mlxService.isModelInstalled(modelId);
315 + if (!ref.mounted) return;
316 + if (!isInstalled) {
317 + state = state.copyWith(
318 + status: OnDeviceEngineStatus.error,
319 + loadedModelId: null,
320 + loadedRuntime: null,
321 + error: 'MLX Model not found. Please download it first.',
322 + );
323 + return;
324 + }
325 +
326 + await _gemmaService.unloadModel();
327 + await _llamaService.unloadModel();
328 + if (!ref.mounted) return;
329 + await _mlxService.loadModel(model);
330 + if (!ref.mounted) return;
331 } else {
332 final isInstalled = await _gemmaService.isModelInstalled(modelId);
333 if (!ref.mounted) return;
@@ -308,6 +342,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
342 }
343
344 await _llamaService.unloadModel();
345 + await _mlxService.unloadModel();
346 if (!ref.mounted) return;
347 await _gemmaService.loadModel(modelId, effectiveBackend);
348 if (!ref.mounted) return;
@@ -327,10 +362,14 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
362 final onDeviceServer = servers
363 .where((s) => s.type == ServerType.onDevice)
364 .firstOrNull;
330 - if (onDeviceServer != null) {
331 - ref
332 - .read(activeServerIdProvider.notifier)
333 - .setActiveServer(onDeviceServer);
365 + if (onDeviceServer != null && ref.mounted) {
366 + Future.microtask(() {
367 + if (ref.mounted) {
368 + ref
369 + .read(activeServerIdProvider.notifier)
370 + .setActiveServer(onDeviceServer);
371 + }
372 + });
373 }
374 }
375
@@ -364,6 +403,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
403 Future<void> unloadModel() async {
404 final gemma = ref.mounted ? _gemmaService : null;
405 final llama = ref.mounted ? _llamaService : null;
406 + final mlx = ref.mounted ? _mlxService : null;
407 if (gemma != null) {
408 try {
409 await gemma.unloadModel();
@@ -378,6 +418,13 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
418 Log.error('Failed to unload llama.cpp model: $e');
419 }
420 }
421 + if (mlx != null) {
422 + try {
423 + await mlx.unloadModel();
424 + } catch (e) {
425 + Log.error('Failed to unload MLX model: $e');
426 + }
427 + }
428 if (ref.mounted) {
429 state = const OnDeviceEngineState();
430 }
@@ -386,6 +433,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
433 Future<void> disposeService() async {
434 _gemmaService.dispose();
435 _llamaService.dispose();
436 + _mlxService.dispose();
437 state = const OnDeviceEngineState();
438 }
439 }
lib/features/servers/providers/server_providers.dart
+7 -1
@@ -158,9 +158,15 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
158 m.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
159 ),
160 fileSize: m.fileSizeBytes,
161 - quantization: m.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
161 + quantization: m.format == OnDeviceModelFormat.gguf
162 + ? 'GGUF'
163 + : m.format == OnDeviceModelFormat.mlx
164 + ? 'MLX 4-bit'
165 + : null,
166 architecture: m.runtime == OnDeviceModelRuntime.llamaCpp
167 ? 'llama.cpp'
168 + : m.runtime == OnDeviceModelRuntime.mlx
169 + ? 'Apple MLX'
170 : null,
171 serverType: ServerType.onDevice,
172 serverId: server.id,
packages/apple_mlx/README.md new
+88
@@ -0,0 +1,88 @@
1 +# Apple MLX Support (`apple_mlx`)
2 +
3 +Local package integrating Apple's **MLX** ecosystem (`lib_mlx`, `speech_mlx`, `mlx_audio`, and `mlx-c`) with Dart & Flutter to run LLM Text Generation, Speech-to-Text, and Text-to-Speech natively on Apple Silicon (iOS and macOS).
4 +
5 +## Capabilities
6 +
7 +### 1. LLM Text Generation (`AppleMlxLlmManager` & `lib_mlx`)
8 +- **Native Apple Silicon Inference**: Runs quantized MLX models (Gemma, Llama 3.2, Qwen 2.5, SmolLM2) directly on device via Unified Memory and Metal.
9 +- **OpenAI-Compatible Local Endpoint**: Zero-overhead localhost HTTP/SSE streaming client.
10 +- **Reasoning / Thinking Support**: Separates `<thinking>` tokens from final assistant content.
11 +- **Tool Calling Support**: Function and tool calling protocol.
12 +
13 +### 2. Speech-To-Text (`AppleMlxSpeechManager` & `speech_mlx` / `mlx_audio`)
14 +- **Parakeet-TDT & Whisper**: High-accuracy local speech recognition.
15 +- **Isolate Offloading**: Dedicated worker isolates keep audio processing and resampling off the UI isolate.
16 +
17 +### 3. Text-To-Speech (`PocketTTS`)
18 +- **Neural Voice Synthesis**: Real-time voice generation with incremental PCM streaming.
19 +- **Voices**: Built-in multi-voice support (`alba`, `marius`, `javert`, `jean`).
20 +
21 +### 4. Conversational Turn Detection (`SmartTurn`)
22 +- **Smart Turn v3.2**: Sub-millisecond endpoint classifier to determine whether a speaker finished speaking during a voice conversation.
23 +
24 +---
25 +
26 +## Quickstart
27 +
28 +### LLM Text Generation
29 +
30 +```dart
31 +import 'package:apple_mlx/apple_mlx.dart';
32 +
33 +Future<void> main() async {
34 + final llm = AppleMlxLlmManager();
35 +
36 + // 1. Load MLX model and launch local server
37 + final serverInfo = await llm.loadAndStartServer(
38 + modelPath: '/path/to/mlx-community/gemma-4-e2b-it-4bit',
39 + modelId: AppleMlxModelIds.gemma4E2b,
40 + thinkingEnabled: true,
41 + );
42 + print('MLX Server running on ${serverInfo.baseUrl}');
43 +
44 + // 2. Stream generation
45 + final stream = llm.generateStream(
46 + messages: [
47 + {'role': 'user', 'content': 'Explain quantum computing in simple terms.'},
48 + ],
49 + temperature: 0.7,
50 + );
51 +
52 + await for (final delta in stream) {
53 + if (delta.reasoningContent != null) {
54 + print('[Thinking] ${delta.reasoningContent}');
55 + }
56 + if (delta.content != null) {
57 + print(delta.content);
58 + }
59 + }
60 +
61 + // 3. Clean up
62 + await llm.dispose();
63 +}
64 +```
65 +
66 +### STT and TTS Audio
67 +
68 +```dart
69 +import 'package:apple_mlx/apple_mlx.dart';
70 +
71 +Future<void> main() async {
72 + final speech = AppleMlxSpeechManager();
73 + await speech.initialize();
74 +
75 + // Transcribe audio
76 + final text = await speech.transcribeWavFile('input.wav');
77 + print('Transcript: $text');
78 +
79 + // Synthesize voice
80 + await speech.synthesizeToWav(
81 + text: 'Hello from Apple MLX on LocalMind!',
82 + outputPath: 'output.wav',
83 + voice: 'alba',
84 + );
85 +
86 + await speech.dispose();
87 +}
88 +```
packages/apple_mlx/build/native_assets/macos/native_assets.json new
+1
@@ -0,0 +1 @@
1 +{"format-version":[1,0,0],"native-assets":{}}
\ No newline at end of file
packages/apple_mlx/build/test_cache/build/11f9252fab9bb4424d40abfb06fd5e81.cache.dill.track.dill
Binary files /dev/null and b/packages/apple_mlx/build/test_cache/build/11f9252fab9bb4424d40abfb06fd5e81.cache.dill.track.dill differ
packages/apple_mlx/build/unit_test_assets/AssetManifest.bin
Binary files /dev/null and b/packages/apple_mlx/build/unit_test_assets/AssetManifest.bin differ
packages/apple_mlx/build/unit_test_assets/FontManifest.json new
+1
@@ -0,0 +1 @@
1 +[]
\ No newline at end of file
packages/apple_mlx/build/unit_test_assets/NOTICES.Z
Binary files /dev/null and b/packages/apple_mlx/build/unit_test_assets/NOTICES.Z differ
packages/apple_mlx/build/unit_test_assets/NativeAssetsManifest.json new
+1
@@ -0,0 +1 @@
1 +{"format-version":[1,0,0],"native-assets":{}}
\ No newline at end of file
packages/apple_mlx/build/unit_test_assets/shaders/ink_sparkle.frag
Binary files /dev/null and b/packages/apple_mlx/build/unit_test_assets/shaders/ink_sparkle.frag differ
packages/apple_mlx/build/unit_test_assets/shaders/stretch_effect.frag
Binary files /dev/null and b/packages/apple_mlx/build/unit_test_assets/shaders/stretch_effect.frag differ
packages/apple_mlx/lib/apple_mlx.dart new
+13
@@ -0,0 +1,13 @@
1 +library;
2 +
3 +// Core MLX packages
4 +export 'package:lib_mlx/lib_mlx.dart';
5 +export 'package:speech_mlx/speech_mlx.dart';
6 +export 'package:mlx_audio/mlx_audio.dart';
7 +export 'package:speech_core/speech_core.dart';
8 +export 'package:audio_core/audio_core.dart';
9 +
10 +// LocalMind MLX Suite
11 +export 'src/mlx_models.dart';
12 +export 'src/mlx_speech_manager.dart';
13 +export 'src/mlx_llm_manager.dart';
packages/apple_mlx/lib/src/mlx_llm_manager.dart new
+179
@@ -0,0 +1,179 @@
1 +import 'dart:async';
2 +import 'package:lib_mlx/lib_mlx.dart';
3 +
4 +/// Represents streaming chunk delta from an MLX LLM generation.
5 +class AppleMlxChatDelta {
6 + /// Regular content delta chunk.
7 + final String? content;
8 +
9 + /// Reasoning / thinking delta chunk (if model supports thinking).
10 + final String? reasoningContent;
11 +
12 + /// Whether generation is finished.
13 + final bool isDone;
14 +
15 + /// Raw chunk data if available.
16 + final Map<String, Object?>? rawData;
17 +
18 + const AppleMlxChatDelta({
19 + this.content,
20 + this.reasoningContent,
21 + this.isDone = false,
22 + this.rawData,
23 + });
24 +}
25 +
26 +/// High-level manager for running MLX LLM text generation models natively on Apple devices.
27 +class AppleMlxLlmManager {
28 + final LibMlxRuntime _runtime;
29 + LibMlxOpenAiClient? _client;
30 + MlxModelHandle? _handle;
31 + MlxServerInfo? _serverInfo;
32 +
33 + AppleMlxLlmManager({LibMlxRuntime? runtime})
34 + : _runtime = runtime ?? const LibMlxRuntime();
35 +
36 + bool get isModelLoaded => _handle != null;
37 + bool get isServerRunning => _serverInfo != null;
38 + MlxServerInfo? get serverInfo => _serverInfo;
39 + MlxModelHandle? get modelHandle => _handle;
40 +
41 + /// Loads an MLX model from disk and starts the local OpenAI-compatible inference server.
42 + Future<MlxServerInfo> loadAndStartServer({
43 + required String modelPath,
44 + String modelId = MlxAvailableModel.defaultHuggingFaceModelId,
45 + bool thinkingEnabled = true,
46 + bool lazyEncoders = true,
47 + int port = 0,
48 + int queueLimit = 1,
49 + }) async {
50 + // Unload existing session if present
51 + await dispose();
52 +
53 + final config = MlxModelConfig(
54 + modelPath: modelPath,
55 + modelId: modelId,
56 + thinkingEnabled: thinkingEnabled,
57 + lazyEncoders: lazyEncoders,
58 + );
59 +
60 + _handle = await _runtime.loadModel(config);
61 +
62 + final serverConfig = MlxServerConfig(
63 + port: port,
64 + modelId: modelId,
65 + queueLimit: queueLimit,
66 + );
67 +
68 + _serverInfo = await _runtime.startServer(_handle!, config: serverConfig);
69 + _client = LibMlxOpenAiClient(baseUri: _serverInfo!.uri);
70 +
71 + return _serverInfo!;
72 + }
73 +
74 + /// Streams chat completions using Server-Sent Events (SSE).
75 + Stream<AppleMlxChatDelta> generateStream({
76 + required List<Map<String, String>> messages,
77 + double temperature = 0.7,
78 + int? maxTokens,
79 + bool thinking = true,
80 + }) async* {
81 + final client = _client;
82 + final serverInfo = _serverInfo;
83 + if (client == null || serverInfo == null) {
84 + throw StateError(
85 + 'AppleMlxLlmManager: Model is not loaded or server is not running. Call loadAndStartServer() first.',
86 + );
87 + }
88 +
89 + final request = <String, Object?>{
90 + 'model': serverInfo.modelId,
91 + 'messages': messages,
92 + 'temperature': temperature,
93 + 'thinking': thinking,
94 + 'max_tokens': ?maxTokens,
95 + };
96 +
97 + final sseStream = client.chatCompletionsStream(request);
98 +
99 + await for (final event in sseStream) {
100 + if (event.done) {
101 + yield const AppleMlxChatDelta(isDone: true);
102 + break;
103 + }
104 +
105 + final choices = event.data?['choices'] as List?;
106 + final choice = choices?.firstOrNull as Map?;
107 + final delta = choice?['delta'] as Map?;
108 +
109 + if (delta != null) {
110 + final content = delta['content'] as String?;
111 + final reasoning = delta['reasoning_content'] as String?;
112 +
113 + if (content != null || reasoning != null) {
114 + yield AppleMlxChatDelta(
115 + content: content,
116 + reasoningContent: reasoning,
117 + rawData: event.data,
118 + );
119 + }
120 + }
121 + }
122 + }
123 +
124 + /// Generates non-streaming completion response.
125 + Future<String> generate({
126 + required List<Map<String, String>> messages,
127 + double temperature = 0.7,
128 + int? maxTokens,
129 + }) async {
130 + final client = _client;
131 + final serverInfo = _serverInfo;
132 + if (client == null || serverInfo == null) {
133 + throw StateError(
134 + 'AppleMlxLlmManager: Model is not loaded or server is not running. Call loadAndStartServer() first.',
135 + );
136 + }
137 +
138 + final request = <String, Object?>{
139 + 'model': serverInfo.modelId,
140 + 'messages': messages,
141 + 'temperature': temperature,
142 + 'max_tokens': ?maxTokens,
143 + };
144 +
145 + final response = await client.chatCompletions(request);
146 + final choices = response['choices'] as List?;
147 + if (choices != null && choices.isNotEmpty) {
148 + final first = choices.first as Map?;
149 + final message = first?['message'] as Map?;
150 + return message?['content'] as String? ?? '';
151 + }
152 + return '';
153 + }
154 +
155 + /// Checks the current runtime and model status.
156 + Future<MlxRuntimeStatus?> checkStatus() async {
157 + final handle = _handle;
158 + if (handle == null) return null;
159 + return _runtime.serverStatus(handle);
160 + }
161 +
162 + /// Stops server, unloads model, and releases memory.
163 + Future<void> dispose() async {
164 + _client?.close();
165 + _client = null;
166 +
167 + final handle = _handle;
168 + if (handle != null) {
169 + try {
170 + await _runtime.stopServer(handle);
171 + } catch (_) {}
172 + try {
173 + await _runtime.unloadModel(handle);
174 + } catch (_) {}
175 + _handle = null;
176 + }
177 + _serverInfo = null;
178 + }
179 +}
packages/apple_mlx/lib/src/mlx_models.dart new
+52
@@ -0,0 +1,52 @@
1 +/// Known and recommended Apple MLX model checkpoints.
2 +class AppleMlxModelIds {
3 + const AppleMlxModelIds._();
4 +
5 + // LLM / Text Generation Models (Hugging Face mlx-community)
6 + static const String gemma22b = 'mlx-community/gemma-2-2b-it-4bit';
7 + static const String llama321b = 'mlx-community/Llama-3.2-1B-Instruct-4bit';
8 + static const String llama323b = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
9 + static const String smolLm2135m = 'mlx-community/SmolLM2-135M-Instruct';
10 + static const String qwen2505b = 'mlx-community/Qwen2.5-0.5B-Instruct-4bit';
11 + static const String qwen2515b = 'mlx-community/Qwen2.5-1.5B-Instruct-4bit';
12 + static const String qwen253b = 'mlx-community/Qwen2.5-3B-Instruct-4bit';
13 +
14 + // Speech-To-Text (STT) Models
15 + static const String parakeetTdt06b = 'mlx-community/parakeet-tdt-0.6b-v2';
16 + static const String whisperTiny = 'mlx-community/whisper-tiny-mlx';
17 + static const String whisperBase = 'mlx-community/whisper-base-mlx';
18 + static const String whisperSmall = 'mlx-community/whisper-small-mlx';
19 +
20 + // Text-To-Speech (TTS) Models
21 + static const String pocketTts = 'mlx-community/pocket-tts';
22 +
23 + // Default PocketTTS voice IDs
24 + static const List<String> defaultVoices = [
25 + 'alba',
26 + 'marius',
27 + 'javert',
28 + 'jean',
29 + ];
30 +}
31 +
32 +/// Configuration options for MLX speech operations.
33 +class AppleMlxConfig {
34 + /// Path or Hugging Face repo ID for STT model.
35 + final String? sttModelId;
36 +
37 + /// Path or Hugging Face repo ID for TTS model.
38 + final String? ttsModelId;
39 +
40 + /// Default voice ID to use for TTS.
41 + final String defaultVoice;
42 +
43 + /// Maximum samples allowed in audio buffer for STT.
44 + final int? maximumInputSamples;
45 +
46 + const AppleMlxConfig({
47 + this.sttModelId = AppleMlxModelIds.parakeetTdt06b,
48 + this.ttsModelId = AppleMlxModelIds.pocketTts,
49 + this.defaultVoice = 'alba',
50 + this.maximumInputSamples,
51 + });
52 +}
packages/apple_mlx/lib/src/mlx_speech_manager.dart new
+107
@@ -0,0 +1,107 @@
1 +import 'dart:async';
2 +import 'package:mlx_audio/mlx_audio.dart' as mlx_native;
3 +import 'package:speech_mlx/speech_mlx.dart';
4 +import 'mlx_models.dart';
5 +
6 +/// Manager for orchestrating MLX Audio STT, TTS, and Turn Completion.
7 +class AppleMlxSpeechManager {
8 + final AppleMlxConfig config;
9 + MlxSpeechProvider? _provider;
10 + MlxSmartTurnScorer? _turnScorer;
11 + bool _isInitialized = false;
12 +
13 + AppleMlxSpeechManager({this.config = const AppleMlxConfig()});
14 +
15 + bool get isInitialized => _isInitialized;
16 +
17 + /// Initializes the provider with configured STT and TTS isolate workers.
18 + Future<void> initialize() async {
19 + if (_isInitialized) return;
20 +
21 + final sttWorker = config.sttModelId != null
22 + ? MlxIsolateBatchWorker(modelPath: config.sttModelId!)
23 + : null;
24 +
25 + final ttsWorker = config.ttsModelId != null
26 + ? MlxIsolateTtsWorker(modelPath: config.ttsModelId!)
27 + : null;
28 +
29 + _provider = MlxSpeechProvider(
30 + worker: sttWorker,
31 + modelId: config.sttModelId ?? 'default',
32 + ttsWorker: ttsWorker,
33 + ttsModelId: config.ttsModelId ?? 'pocket-tts',
34 + voiceIds: AppleMlxModelIds.defaultVoices,
35 + );
36 +
37 + _isInitialized = true;
38 + }
39 +
40 + /// Transcribes a WAV audio file directly using MLX Audio.
41 + Future<String> transcribeWavFile(String wavFilePath) async {
42 + final audio = await mlx_native.loadAudioMono16k(wavFilePath);
43 + final modelId = config.sttModelId ?? AppleMlxModelIds.parakeetTdt06b;
44 + final stt = await mlx_native.STT.loadModel(modelId);
45 + try {
46 + final output = stt.generate(audio);
47 + return output.text;
48 + } finally {
49 + stt.close();
50 + }
51 + }
52 +
53 + /// Synthesizes text to a WAV audio file using PocketTTS on MLX.
54 + Future<mlx_native.TtsOutput> synthesizeToWav({
55 + required String text,
56 + required String outputPath,
57 + String? voice,
58 + void Function(mlx_native.TtsAudioChunk chunk)? onAudioChunk,
59 + }) async {
60 + final modelId = config.ttsModelId ?? AppleMlxModelIds.pocketTts;
61 + final tts = await mlx_native.TTS.loadModel(modelId);
62 + try {
63 + final selectedVoice = voice ?? config.defaultVoice;
64 + final out = tts.generate(
65 + text,
66 + voice: selectedVoice,
67 + onAudioChunk: (chunk) {
68 + onAudioChunk?.call(chunk);
69 + },
70 + );
71 + mlx_native.writeWavFloat32(outputPath, out.samples, out.sampleRate);
72 + return out;
73 + } finally {
74 + tts.close();
75 + }
76 + }
77 +
78 + /// Initializes SmartTurn endpoint / turn-completion detector.
79 + Future<void> initializeTurnScorer({String? modelDirectory}) async {
80 + if (_turnScorer != null) return;
81 + _turnScorer = MlxSmartTurnScorer(
82 + worker: MlxIsolateTurnWorker(modelDirectory: modelDirectory),
83 + );
84 + }
85 +
86 + /// Evaluates whether user speech is complete using SmartTurn classifier.
87 + Future<mlx_native.SmartTurnEndpointOutput> scoreTurn(
88 + String pauseWavPath,
89 + ) async {
90 + final turn = await mlx_native.SmartTurn.load();
91 + try {
92 + final window = await mlx_native.loadAudioMono16k(pauseWavPath);
93 + return turn.predictEndpoint(window);
94 + } finally {
95 + turn.close();
96 + }
97 + }
98 +
99 + /// Disposes and cleans up MLX workers and isolates.
100 + Future<void> dispose() async {
101 + await _provider?.close();
102 + await _turnScorer?.close();
103 + _provider = null;
104 + _turnScorer = null;
105 + _isInitialized = false;
106 + }
107 +}
packages/apple_mlx/pubspec.lock new
+301
@@ -0,0 +1,301 @@
1 +# Generated by pub
2 +# See https://dart.dev/tools/pub/glossary#lockfile
3 +packages:
4 + args:
5 + dependency: transitive
6 + description:
7 + name: args
8 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
9 + url: "https://pub.dev"
10 + source: hosted
11 + version: "2.7.0"
12 + async:
13 + dependency: transitive
14 + description:
15 + name: async
16 + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
17 + url: "https://pub.dev"
18 + source: hosted
19 + version: "2.13.1"
20 + audio_core:
21 + dependency: "direct main"
22 + description:
23 + name: audio_core
24 + sha256: "6996bd8f5df465a6fa8d08736487bd53f8b78d4ffee411fcfa4dbfdc2e2437ad"
25 + url: "https://pub.dev"
26 + source: hosted
27 + version: "0.1.0"
28 + boolean_selector:
29 + dependency: transitive
30 + description:
31 + name: boolean_selector
32 + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
33 + url: "https://pub.dev"
34 + source: hosted
35 + version: "2.1.2"
36 + characters:
37 + dependency: transitive
38 + description:
39 + name: characters
40 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
41 + url: "https://pub.dev"
42 + source: hosted
43 + version: "1.4.1"
44 + clock:
45 + dependency: transitive
46 + description:
47 + name: clock
48 + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
49 + url: "https://pub.dev"
50 + source: hosted
51 + version: "1.1.2"
52 + collection:
53 + dependency: transitive
54 + description:
55 + name: collection
56 + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
57 + url: "https://pub.dev"
58 + source: hosted
59 + version: "1.19.1"
60 + crypto:
61 + dependency: transitive
62 + description:
63 + name: crypto
64 + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
65 + url: "https://pub.dev"
66 + source: hosted
67 + version: "3.0.7"
68 + fake_async:
69 + dependency: transitive
70 + description:
71 + name: fake_async
72 + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
73 + url: "https://pub.dev"
74 + source: hosted
75 + version: "1.3.3"
76 + ffi:
77 + dependency: transitive
78 + description:
79 + name: ffi
80 + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
81 + url: "https://pub.dev"
82 + source: hosted
83 + version: "2.2.0"
84 + flutter:
85 + dependency: "direct main"
86 + description: flutter
87 + source: sdk
88 + version: "0.0.0"
89 + flutter_test:
90 + dependency: "direct dev"
91 + description: flutter
92 + source: sdk
93 + version: "0.0.0"
94 + http:
95 + dependency: "direct main"
96 + description:
97 + name: http
98 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
99 + url: "https://pub.dev"
100 + source: hosted
101 + version: "1.6.0"
102 + http_parser:
103 + dependency: transitive
104 + description:
105 + name: http_parser
106 + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
107 + url: "https://pub.dev"
108 + source: hosted
109 + version: "4.1.2"
110 + leak_tracker:
111 + dependency: transitive
112 + description:
113 + name: leak_tracker
114 + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
115 + url: "https://pub.dev"
116 + source: hosted
117 + version: "11.0.2"
118 + leak_tracker_flutter_testing:
119 + dependency: transitive
120 + description:
121 + name: leak_tracker_flutter_testing
122 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
123 + url: "https://pub.dev"
124 + source: hosted
125 + version: "3.0.10"
126 + leak_tracker_testing:
127 + dependency: transitive
128 + description:
129 + name: leak_tracker_testing
130 + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
131 + url: "https://pub.dev"
132 + source: hosted
133 + version: "3.0.2"
134 + lib_mlx:
135 + dependency: "direct main"
136 + description:
137 + name: lib_mlx
138 + sha256: dcb32359d834425cabb820de7de051a8f85d536e96218abb1d68d5973cf7044d
139 + url: "https://pub.dev"
140 + source: hosted
141 + version: "0.1.1"
142 + matcher:
143 + dependency: transitive
144 + description:
145 + name: matcher
146 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
147 + url: "https://pub.dev"
148 + source: hosted
149 + version: "0.12.19"
150 + material_color_utilities:
151 + dependency: transitive
152 + description:
153 + name: material_color_utilities
154 + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
155 + url: "https://pub.dev"
156 + source: hosted
157 + version: "0.13.0"
158 + meta:
159 + dependency: transitive
160 + description:
161 + name: meta
162 + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
163 + url: "https://pub.dev"
164 + source: hosted
165 + version: "1.18.0"
166 + mlx:
167 + dependency: transitive
168 + description:
169 + name: mlx
170 + sha256: d735465e4a8f55dc209d7e53aaf3039b9063101aa3cced0da2843b64e884a721
171 + url: "https://pub.dev"
172 + source: hosted
173 + version: "0.2.0"
174 + mlx_audio:
175 + dependency: "direct main"
176 + description:
177 + name: mlx_audio
178 + sha256: "5a94ffaea1bce9e75f3757722e1a92dd8514a3c8bb139377a07b4e7f6c304874"
179 + url: "https://pub.dev"
180 + source: hosted
181 + version: "0.2.2"
182 + path:
183 + dependency: transitive
184 + description:
185 + name: path
186 + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
187 + url: "https://pub.dev"
188 + source: hosted
189 + version: "1.9.1"
190 + sky_engine:
191 + dependency: transitive
192 + description: flutter
193 + source: sdk
194 + version: "0.0.0"
195 + source_span:
196 + dependency: transitive
197 + description:
198 + name: source_span
199 + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
200 + url: "https://pub.dev"
201 + source: hosted
202 + version: "1.10.2"
203 + speech_core:
204 + dependency: "direct main"
205 + description:
206 + name: speech_core
207 + sha256: bc0cd35c890528638ba4a55792e05a8b5afe23bfc4b2ba30873ebb3813c97bad
208 + url: "https://pub.dev"
209 + source: hosted
210 + version: "0.2.0"
211 + speech_mlx:
212 + dependency: "direct main"
213 + description:
214 + name: speech_mlx
215 + sha256: "35aae05aa0e5b94695fd0038cb2dcada83e4c310e0268e4ef29a3ece7973bd25"
216 + url: "https://pub.dev"
217 + source: hosted
218 + version: "0.2.0"
219 + stack_trace:
220 + dependency: transitive
221 + description:
222 + name: stack_trace
223 + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
224 + url: "https://pub.dev"
225 + source: hosted
226 + version: "1.12.1"
227 + stream_channel:
228 + dependency: transitive
229 + description:
230 + name: stream_channel
231 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
232 + url: "https://pub.dev"
233 + source: hosted
234 + version: "2.1.4"
235 + string_scanner:
236 + dependency: transitive
237 + description:
238 + name: string_scanner
239 + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
240 + url: "https://pub.dev"
241 + source: hosted
242 + version: "1.4.1"
243 + term_glyph:
244 + dependency: transitive
245 + description:
246 + name: term_glyph
247 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
248 + url: "https://pub.dev"
249 + source: hosted
250 + version: "1.2.2"
251 + test_api:
252 + dependency: transitive
253 + description:
254 + name: test_api
255 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
256 + url: "https://pub.dev"
257 + source: hosted
258 + version: "0.7.11"
259 + typed_data:
260 + dependency: transitive
261 + description:
262 + name: typed_data
263 + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
264 + url: "https://pub.dev"
265 + source: hosted
266 + version: "1.4.0"
267 + vector_math:
268 + dependency: transitive
269 + description:
270 + name: vector_math
271 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
272 + url: "https://pub.dev"
273 + source: hosted
274 + version: "2.2.0"
275 + vm_service:
276 + dependency: transitive
277 + description:
278 + name: vm_service
279 + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
280 + url: "https://pub.dev"
281 + source: hosted
282 + version: "15.3.0"
283 + wav:
284 + dependency: transitive
285 + description:
286 + name: wav
287 + sha256: "2c932c74a531befedcb1d44bb24dd95fea8c801590d560c73fa94f35c5ef6551"
288 + url: "https://pub.dev"
289 + source: hosted
290 + version: "1.5.0"
291 + web:
292 + dependency: transitive
293 + description:
294 + name: web
295 + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
296 + url: "https://pub.dev"
297 + source: hosted
298 + version: "1.1.1"
299 +sdks:
300 + dart: ">=3.12.0 <4.0.0"
301 + flutter: ">=3.44.0"
packages/apple_mlx/pubspec.yaml new
+22
@@ -0,0 +1,22 @@
1 +name: apple_mlx
2 +description: Local package bridging Apple MLX models (LLM text generation, STT, TTS, turn detection) for LocalMind.
3 +version: 0.1.0
4 +publish_to: none
5 +
6 +environment:
7 + sdk: ">=3.12.0 <4.0.0"
8 + flutter: ">=3.44.0"
9 +
10 +dependencies:
11 + flutter:
12 + sdk: flutter
13 + lib_mlx: ^0.1.1
14 + speech_mlx: ^0.2.0
15 + mlx_audio: ^0.2.2
16 + speech_core: ^0.2.0
17 + audio_core: ^0.1.0
18 + http: ^1.2.0
19 +
20 +dev_dependencies:
21 + flutter_test:
22 + sdk: flutter
packages/apple_mlx/test/apple_mlx_test.dart new
+44
@@ -0,0 +1,44 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:apple_mlx/apple_mlx.dart';
3 +
4 +void main() {
5 + group('AppleMlxSpeechManager', () {
6 + test('initializes with default configuration', () {
7 + final manager = AppleMlxSpeechManager();
8 + expect(manager.isInitialized, isFalse);
9 + expect(manager.config.defaultVoice, equals('alba'));
10 + expect(
11 + manager.config.sttModelId,
12 + equals(AppleMlxModelIds.parakeetTdt06b),
13 + );
14 + expect(manager.config.ttsModelId, equals(AppleMlxModelIds.pocketTts));
15 + });
16 +
17 + test('supports custom model configuration', () {
18 + final config = AppleMlxConfig(
19 + sttModelId: AppleMlxModelIds.whisperSmall,
20 + ttsModelId: AppleMlxModelIds.pocketTts,
21 + defaultVoice: 'marius',
22 + );
23 + final manager = AppleMlxSpeechManager(config: config);
24 + expect(manager.config.defaultVoice, equals('marius'));
25 + expect(manager.config.sttModelId, equals(AppleMlxModelIds.whisperSmall));
26 + });
27 + });
28 +
29 + group('AppleMlxLlmManager', () {
30 + test('instantiates with uninitialized state', () {
31 + final llm = AppleMlxLlmManager();
32 + expect(llm.isModelLoaded, isFalse);
33 + expect(llm.isServerRunning, isFalse);
34 + expect(llm.serverInfo, isNull);
35 + expect(llm.modelHandle, isNull);
36 + });
37 +
38 + test('exposes popular MLX model identifiers', () {
39 + expect(AppleMlxModelIds.gemma22b, isNotEmpty);
40 + expect(AppleMlxModelIds.llama321b, isNotEmpty);
41 + expect(AppleMlxModelIds.qwen2505b, isNotEmpty);
42 + });
43 + });
44 +}
pubspec.lock
+63
@@ -25,6 +25,13 @@ packages:
25 url: "https://pub.dev"
26 source: hosted
27 version: "2.2.0"
28 + apple_mlx:
29 + dependency: "direct main"
30 + description:
31 + path: "packages/apple_mlx"
32 + relative: true
33 + source: path
34 + version: "0.1.0"
35 archive:
36 dependency: "direct main"
37 description:
@@ -49,6 +56,14 @@ packages:
56 url: "https://pub.dev"
57 source: hosted
58 version: "2.13.1"
59 + audio_core:
60 + dependency: transitive
61 + description:
62 + name: audio_core
63 + sha256: "6996bd8f5df465a6fa8d08736487bd53f8b78d4ffee411fcfa4dbfdc2e2437ad"
64 + url: "https://pub.dev"
65 + source: hosted
66 + version: "0.1.0"
67 audio_service:
68 dependency: transitive
69 description:
@@ -1093,6 +1108,14 @@ packages:
1108 url: "https://pub.dev"
1109 source: hosted
1110 version: "3.0.2"
1111 + lib_mlx:
1112 + dependency: transitive
1113 + description:
1114 + name: lib_mlx
1115 + sha256: dcb32359d834425cabb820de7de051a8f85d536e96218abb1d68d5973cf7044d
1116 + url: "https://pub.dev"
1117 + source: hosted
1118 + version: "0.1.1"
1119 lints:
1120 dependency: transitive
1121 description:
@@ -1181,6 +1204,22 @@ packages:
1204 url: "https://pub.dev"
1205 source: hosted
1206 version: "2.0.0"
1207 + mlx:
1208 + dependency: transitive
1209 + description:
1210 + name: mlx
1211 + sha256: d735465e4a8f55dc209d7e53aaf3039b9063101aa3cced0da2843b64e884a721
1212 + url: "https://pub.dev"
1213 + source: hosted
1214 + version: "0.2.0"
1215 + mlx_audio:
1216 + dependency: transitive
1217 + description:
1218 + name: mlx_audio
1219 + sha256: "5a94ffaea1bce9e75f3757722e1a92dd8514a3c8bb139377a07b4e7f6c304874"
1220 + url: "https://pub.dev"
1221 + source: hosted
1222 + version: "0.2.2"
1223 mutex:
1224 dependency: transitive
1225 description:
@@ -1762,6 +1801,22 @@ packages:
1801 url: "https://pub.dev"
1802 source: hosted
1803 version: "1.10.2"
1804 + speech_core:
1805 + dependency: transitive
1806 + description:
1807 + name: speech_core
1808 + sha256: bc0cd35c890528638ba4a55792e05a8b5afe23bfc4b2ba30873ebb3813c97bad
1809 + url: "https://pub.dev"
1810 + source: hosted
1811 + version: "0.2.0"
1812 + speech_mlx:
1813 + dependency: transitive
1814 + description:
1815 + name: speech_mlx
1816 + sha256: "35aae05aa0e5b94695fd0038cb2dcada83e4c310e0268e4ef29a3ece7973bd25"
1817 + url: "https://pub.dev"
1818 + source: hosted
1819 + version: "0.2.0"
1820 speech_to_text:
1821 dependency: "direct main"
1822 description:
@@ -2099,6 +2154,14 @@ packages:
2154 url: "https://pub.dev"
2155 source: hosted
2156 version: "1.2.1"
2157 + wav:
2158 + dependency: transitive
2159 + description:
2160 + name: wav
2161 + sha256: "2c932c74a531befedcb1d44bb24dd95fea8c801590d560c73fa94f35c5ef6551"
2162 + url: "https://pub.dev"
2163 + source: hosted
2164 + version: "1.5.0"
2165 web:
2166 dependency: transitive
2167 description:
pubspec.yaml
+2
@@ -89,6 +89,8 @@ dependencies:
89 auto_size_text_plus: ^3.0.2
90 device_calendar_plus: ^0.8.0
91 home_widget: ^0.9.3
92 + apple_mlx:
93 + path: packages/apple_mlx
94
95 dev_dependencies:
96 flutter_test:
test/features/os_widget/os_widget_snapshot_provider_test.dart
+67 -60
@@ -28,26 +28,29 @@ class _StubServersNotifier extends ServersNotifier {
28
29 void main() {
30 group('osWidgetSnapshotProvider', () {
31 - test('returns unconfigured snapshot when no default model is set', () async {
32 - SharedPreferences.setMockInitialValues({});
33 - final prefs = await SharedPreferences.getInstance();
34 -
35 - final container = ProviderContainer(
36 - overrides: [
37 - sharedPreferencesProvider.overrideWithValue(prefs),
38 - settingsProvider.overrideWith(_TestSettingsNotifier.new),
39 - serversProvider.overrideWith(_StubServersNotifier.new),
40 - ],
41 - );
42 - addTearDown(container.dispose);
43 -
44 - // Force settings to build
45 - container.read(settingsProvider);
46 -
47 - final snapshot = container.read(osWidgetSnapshotProvider);
48 - expect(snapshot.isConfigured, isFalse);
49 - expect(snapshot.modelName, isNull);
50 - });
31 + test(
32 + 'returns unconfigured snapshot when no default model is set',
33 + () async {
34 + SharedPreferences.setMockInitialValues({});
35 + final prefs = await SharedPreferences.getInstance();
36 +
37 + final container = ProviderContainer(
38 + overrides: [
39 + sharedPreferencesProvider.overrideWithValue(prefs),
40 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
41 + serversProvider.overrideWith(_StubServersNotifier.new),
42 + ],
43 + );
44 + addTearDown(container.dispose);
45 +
46 + // Force settings to build
47 + container.read(settingsProvider);
48 +
49 + final snapshot = container.read(osWidgetSnapshotProvider);
50 + expect(snapshot.isConfigured, isFalse);
51 + expect(snapshot.modelName, isNull);
52 + },
53 + );
54
55 test('returns configured snapshot with model display name', () async {
56 SharedPreferences.setMockInitialValues({});
@@ -81,7 +84,9 @@ void main() {
84 () => _TestSettingsNotifier(customSettings: settings),
85 ),
86 serversProvider.overrideWith(() => _StubServersNotifier([server])),
84 - availableModelsProvider(server.id).overrideWith((ref) async => [model]),
87 + availableModelsProvider(
88 + server.id,
89 + ).overrideWith((ref) async => [model]),
90 ],
91 );
92 addTearDown(container.dispose);
@@ -94,43 +99,45 @@ void main() {
99 expect(snapshot.modelName, 'Llama 3 8B');
100 });
101
97 - test('falls back to model id when model is missing from available list',
98 - () async {
99 - SharedPreferences.setMockInitialValues({});
100 - final prefs = await SharedPreferences.getInstance();
101 -
102 - final server = Server(
103 - id: 'srv-1',
104 - name: 'Test Server',
105 - host: 'http://localhost',
106 - port: 11434,
107 - type: ServerType.ollama,
108 - createdAt: DateTime.now(),
109 - lastConnectedAt: DateTime.now(),
110 - );
111 -
112 - final settings = AppSettings(
113 - defaultModelId: 'missing-model',
114 - defaultModelServerId: server.id,
115 - );
116 -
117 - final container = ProviderContainer(
118 - overrides: [
119 - sharedPreferencesProvider.overrideWithValue(prefs),
120 - settingsProvider.overrideWith(
121 - () => _TestSettingsNotifier(customSettings: settings),
122 - ),
123 - serversProvider.overrideWith(() => _StubServersNotifier([server])),
124 - availableModelsProvider(server.id).overrideWith((ref) async => []),
125 - ],
126 - );
127 - addTearDown(container.dispose);
128 -
129 - await container.read(availableModelsProvider(server.id).future);
130 -
131 - final snapshot = container.read(osWidgetSnapshotProvider);
132 - expect(snapshot.isConfigured, isTrue);
133 - expect(snapshot.modelName, 'missing-model');
134 - });
102 + test(
103 + 'falls back to model id when model is missing from available list',
104 + () async {
105 + SharedPreferences.setMockInitialValues({});
106 + final prefs = await SharedPreferences.getInstance();
107 +
108 + final server = Server(
109 + id: 'srv-1',
110 + name: 'Test Server',
111 + host: 'http://localhost',
112 + port: 11434,
113 + type: ServerType.ollama,
114 + createdAt: DateTime.now(),
115 + lastConnectedAt: DateTime.now(),
116 + );
117 +
118 + final settings = AppSettings(
119 + defaultModelId: 'missing-model',
120 + defaultModelServerId: server.id,
121 + );
122 +
123 + final container = ProviderContainer(
124 + overrides: [
125 + sharedPreferencesProvider.overrideWithValue(prefs),
126 + settingsProvider.overrideWith(
127 + () => _TestSettingsNotifier(customSettings: settings),
128 + ),
129 + serversProvider.overrideWith(() => _StubServersNotifier([server])),
130 + availableModelsProvider(server.id).overrideWith((ref) async => []),
131 + ],
132 + );
133 + addTearDown(container.dispose);
134 +
135 + await container.read(availableModelsProvider(server.id).future);
136 +
137 + final snapshot = container.read(osWidgetSnapshotProvider);
138 + expect(snapshot.isConfigured, isTrue);
139 + expect(snapshot.modelName, 'missing-model');
140 + },
141 + );
142 });
136 -}
\ No newline at end of file
143 +}