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