12 files changed
+989
-20
lib/features/on_device/components/model_card.dart
+3
@@ -1,3 +1,4 @@
1
+import '../views/components/imported_model_projector_setting.dart';
2
import '../views/components/imported_model_reasoning_setting.dart';
3
import 'package:flutter_gemma_builtin_ai/flutter_gemma_builtin_ai.dart';
4
import 'package:hugeicons/hugeicons.dart';
@@ -93,6 +94,8 @@ class ModelCard extends ConsumerWidget {
94
const SizedBox(height: 8),
95
_CapabilityChips(model: model, theme: theme),
96
if (model.isImported) ImportedModelReasoningSetting(model: model),
97
+ if (model.isImported && model.format == OnDeviceModelFormat.gguf)
98
+ ImportedModelProjectorSetting(model: model),
99
const SizedBox(height: 8),
100
_ModelMetaRow(model: model, theme: theme, l10n: l10n),
101
const SizedBox(height: 8),
lib/features/on_device/data/imported_gguf_model_repository.dart
+314
-4
@@ -10,9 +10,12 @@ import 'package:shared_preferences/shared_preferences.dart';
10
import 'models/on_device_model.dart';
11
12
class ImportedGgufModelMetadata {
13
+ static const _unset = Object();
14
+
15
final String id;
16
final String name;
17
final String filePath;
18
+ final String? projectorPath;
19
final int fileSizeBytes;
20
final DateTime importedAt;
21
final OnDeviceImportedSource source;
@@ -23,6 +26,7 @@ class ImportedGgufModelMetadata {
26
required this.id,
27
required this.name,
28
required this.filePath,
29
+ this.projectorPath,
30
required this.fileSizeBytes,
31
required this.importedAt,
32
required this.source,
@@ -31,6 +35,10 @@ class ImportedGgufModelMetadata {
35
});
36
37
String get fileName => p.basename(filePath);
38
+ String? get projectorFileName =>
39
+ projectorPath != null && projectorPath!.isNotEmpty
40
+ ? p.basename(projectorPath!)
41
+ : null;
42
43
String get sourceLabel {
44
switch (source) {
@@ -46,8 +54,36 @@ class ImportedGgufModelMetadata {
54
return max(2048, (fileSizeMb * 1.3).ceil());
55
}
56
57
+ ImportedGgufModelMetadata copyWith({
58
+ String? id,
59
+ String? name,
60
+ String? filePath,
61
+ Object? projectorPath = _unset,
62
+ int? fileSizeBytes,
63
+ DateTime? importedAt,
64
+ OnDeviceImportedSource? source,
65
+ String? sourceUrl,
66
+ bool? reasoningEnabled,
67
+ }) {
68
+ return ImportedGgufModelMetadata(
69
+ id: id ?? this.id,
70
+ name: name ?? this.name,
71
+ filePath: filePath ?? this.filePath,
72
+ projectorPath: identical(projectorPath, _unset)
73
+ ? this.projectorPath
74
+ : projectorPath as String?,
75
+ fileSizeBytes: fileSizeBytes ?? this.fileSizeBytes,
76
+ importedAt: importedAt ?? this.importedAt,
77
+ source: source ?? this.source,
78
+ sourceUrl: sourceUrl ?? this.sourceUrl,
79
+ reasoningEnabled: reasoningEnabled ?? this.reasoningEnabled,
80
+ );
81
+ }
82
+
83
OnDeviceModel toOnDeviceModel() {
84
final isHuggingFace = source == OnDeviceImportedSource.huggingFace;
85
+ final hasProjector =
86
+ projectorPath != null && projectorPath!.trim().isNotEmpty;
87
return OnDeviceModel(
88
id: id,
89
name: name,
@@ -66,6 +102,8 @@ class ImportedGgufModelMetadata {
102
runtime: OnDeviceModelRuntime.llamaCpp,
103
format: OnDeviceModelFormat.gguf,
104
localPath: filePath,
105
+ projectorPath: projectorPath,
106
+ supportsVision: hasProjector,
107
importedAt: importedAt,
108
isImported: true,
109
importedSource: source,
@@ -78,6 +116,7 @@ class ImportedGgufModelMetadata {
116
'id': id,
117
'name': name,
118
'filePath': filePath,
119
+ 'projectorPath': projectorPath,
120
'fileSizeBytes': fileSizeBytes,
121
'importedAt': importedAt.toIso8601String(),
122
'source': source.name,
@@ -91,6 +130,7 @@ class ImportedGgufModelMetadata {
130
id: json['id'] as String,
131
name: json['name'] as String,
132
filePath: json['filePath'] as String,
133
+ projectorPath: json['projectorPath'] as String?,
134
fileSizeBytes: (json['fileSizeBytes'] as num).toInt(),
135
importedAt: DateTime.parse(json['importedAt'] as String),
136
source: _sourceFromJson(json['source']),
@@ -145,24 +185,47 @@ class ImportedGgufModelRepository {
185
await saveAll(models);
186
}
187
188
+ static bool isProjectorFileName(String name) {
189
+ final lower = name.toLowerCase();
190
+ return lower.contains('mmproj') || lower.contains('projector');
191
+ }
192
+
193
Future<List<ImportedGgufModelMetadata>> loadExisting() async {
194
final models = load();
195
final existing = <ImportedGgufModelMetadata>[];
196
+ var hasChanges = false;
197
198
for (final model in models) {
199
if (await File(model.filePath).exists()) {
154
- existing.add(model);
200
+ if (model.projectorPath != null &&
201
+ !await File(model.projectorPath!).exists()) {
202
+ existing.add(model.copyWith(projectorPath: null));
203
+ hasChanges = true;
204
+ } else {
205
+ existing.add(model);
206
+ }
207
+ } else {
208
+ hasChanges = true;
209
+ if (model.projectorPath != null) {
210
+ final proj = File(model.projectorPath!);
211
+ if (await proj.exists()) {
212
+ await proj.delete();
213
+ }
214
+ }
215
}
216
}
217
158
- if (existing.length != models.length) {
218
+ if (hasChanges || existing.length != models.length) {
219
await saveAll(existing);
220
}
221
222
return existing;
223
}
224
165
- Future<ImportedGgufModelMetadata> importFromPath(String sourcePath) async {
225
+ Future<ImportedGgufModelMetadata> importFromPath(
226
+ String sourcePath, {
227
+ String? projectorPath,
228
+ }) async {
229
final source = File(sourcePath);
230
if (!sourcePath.toLowerCase().endsWith('.gguf')) {
231
throw const FormatException(
@@ -176,20 +239,75 @@ class ImportedGgufModelRepository {
239
);
240
}
241
179
- final dir = await _modelsDirectory();
242
final originalName = p.basename(sourcePath);
243
+ if (isProjectorFileName(originalName) && projectorPath == null) {
244
+ throw const FormatException(
245
+ 'The selected file appears to be a multimodal vision projector (mmproj), '
246
+ 'not a standalone model. Please select the main model file or attach this '
247
+ 'projector to an existing model.',
248
+ );
249
+ }
250
+
251
+ String? resolvedProjectorPath = projectorPath;
252
+ if (resolvedProjectorPath == null) {
253
+ try {
254
+ final parentDir = source.parent;
255
+ if (parentDir.existsSync()) {
256
+ final candidates = parentDir
257
+ .listSync()
258
+ .whereType<File>()
259
+ .where((f) =>
260
+ f.path.toLowerCase().endsWith('.gguf') &&
261
+ isProjectorFileName(p.basename(f.path)))
262
+ .toList();
263
+ if (candidates.isNotEmpty) {
264
+ final modelBase =
265
+ p.basenameWithoutExtension(sourcePath).toLowerCase();
266
+ final match = candidates.firstWhere(
267
+ (c) {
268
+ final cName =
269
+ p.basenameWithoutExtension(c.path).toLowerCase();
270
+ final sharedTokens = modelBase
271
+ .split(RegExp(r'[-_.]'))
272
+ .where((t) => t.length > 2);
273
+ return sharedTokens.any((t) => cName.contains(t));
274
+ },
275
+ orElse: () => candidates.first,
276
+ );
277
+ resolvedProjectorPath = match.path;
278
+ }
279
+ }
280
+ } catch (_) {}
281
+ }
282
+
283
+ final dir = await _modelsDirectory();
284
final id = _createId(originalName);
285
final fileName = '$id-${_sanitizeFileName(originalName)}';
286
final target = File(p.join(dir.path, fileName));
287
+ File? targetProjector;
288
289
try {
290
await source.copy(target.path);
291
await _validateGgufFile(target);
292
293
+ if (resolvedProjectorPath != null) {
294
+ final projSource = File(resolvedProjectorPath);
295
+ if (await projSource.exists() &&
296
+ projSource.path.toLowerCase().endsWith('.gguf')) {
297
+ final projOriginalName = p.basename(resolvedProjectorPath);
298
+ final projFileName =
299
+ '$id-mmproj-${_sanitizeFileName(projOriginalName)}';
300
+ targetProjector = File(p.join(dir.path, projFileName));
301
+ await projSource.copy(targetProjector.path);
302
+ await _validateGgufFile(targetProjector);
303
+ }
304
+ }
305
+
306
final metadata = ImportedGgufModelMetadata(
307
id: id,
308
name: _displayNameFromFileName(originalName),
309
filePath: target.path,
310
+ projectorPath: targetProjector?.path,
311
fileSizeBytes: await target.length(),
312
importedAt: DateTime.now(),
313
source: OnDeviceImportedSource.localFile,
@@ -202,12 +320,146 @@ class ImportedGgufModelRepository {
320
if (await target.exists()) {
321
await target.delete();
322
}
323
+ if (targetProjector != null && await targetProjector.exists()) {
324
+ await targetProjector.delete();
325
+ }
326
rethrow;
327
}
328
}
329
330
+ Future<List<ImportedGgufModelMetadata>> importFromPaths(
331
+ List<String> paths,
332
+ ) async {
333
+ if (paths.isEmpty) return [];
334
+ if (paths.length == 1) {
335
+ return [await importFromPath(paths.single)];
336
+ }
337
+
338
+ final projectors = paths
339
+ .where((filePath) => isProjectorFileName(p.basename(filePath)))
340
+ .toList();
341
+ final models = paths
342
+ .where((filePath) => !isProjectorFileName(p.basename(filePath)))
343
+ .toList();
344
+
345
+ if (models.isEmpty && projectors.isNotEmpty) {
346
+ throw const FormatException(
347
+ 'All selected files appear to be vision projectors (mmproj). '
348
+ 'Please select a main model file or attach these projectors to existing models.',
349
+ );
350
+ }
351
+
352
+ final results = <ImportedGgufModelMetadata>[];
353
+ if (models.length == 1 && projectors.isNotEmpty) {
354
+ results.add(
355
+ await importFromPath(models.single, projectorPath: projectors.first),
356
+ );
357
+ return results;
358
+ }
359
+
360
+ for (final modelPath in models) {
361
+ final modelBase = p.basenameWithoutExtension(modelPath).toLowerCase();
362
+ final matchingProjector = projectors.cast<String?>().firstWhere(
363
+ (projPath) {
364
+ if (projPath == null) return false;
365
+ final projBase =
366
+ p.basenameWithoutExtension(projPath).toLowerCase();
367
+ final sharedTokens = modelBase
368
+ .split(RegExp(r'[-_.]'))
369
+ .where((t) => t.length > 2);
370
+ return sharedTokens.any((t) => projBase.contains(t));
371
+ },
372
+ orElse: () => null,
373
+ );
374
+
375
+ results.add(
376
+ await importFromPath(modelPath, projectorPath: matchingProjector),
377
+ );
378
+ }
379
+
380
+ return results;
381
+ }
382
+
383
+ Future<ImportedGgufModelMetadata> attachProjector(
384
+ String modelId,
385
+ String projectorSourcePath,
386
+ ) async {
387
+ final projSource = File(projectorSourcePath);
388
+ if (!projectorSourcePath.toLowerCase().endsWith('.gguf')) {
389
+ throw const FormatException(
390
+ 'Only GGUF projector files are supported.',
391
+ );
392
+ }
393
+ if (!await projSource.exists()) {
394
+ throw FileSystemException(
395
+ 'Selected projector file does not exist',
396
+ projectorSourcePath,
397
+ );
398
+ }
399
+
400
+ final models = load();
401
+ final index = models.indexWhere((m) => m.id == modelId);
402
+ if (index < 0) {
403
+ throw StateError('Imported model no longer exists');
404
+ }
405
+
406
+ final dir = await _modelsDirectory();
407
+ final projOriginalName = p.basename(projectorSourcePath);
408
+ final projFileName =
409
+ '$modelId-mmproj-${_sanitizeFileName(projOriginalName)}';
410
+ final targetProjector = File(p.join(dir.path, projFileName));
411
+
412
+ try {
413
+ await projSource.copy(targetProjector.path);
414
+ await _validateGgufFile(targetProjector);
415
+
416
+ final oldProjPath = models[index].projectorPath;
417
+ if (oldProjPath != null && oldProjPath != targetProjector.path) {
418
+ final oldProj = File(oldProjPath);
419
+ if (await oldProj.exists() && p.isWithin(dir.path, oldProj.path)) {
420
+ await oldProj.delete();
421
+ }
422
+ }
423
+
424
+ final updated = models[index].copyWith(
425
+ projectorPath: targetProjector.path,
426
+ );
427
+ models[index] = updated;
428
+ await saveAll(models);
429
+ return updated;
430
+ } catch (_) {
431
+ if (await targetProjector.exists()) {
432
+ await targetProjector.delete();
433
+ }
434
+ rethrow;
435
+ }
436
+ }
437
+
438
+ Future<ImportedGgufModelMetadata> removeProjector(String modelId) async {
439
+ final models = load();
440
+ final index = models.indexWhere((m) => m.id == modelId);
441
+ if (index < 0) {
442
+ throw StateError('Imported model no longer exists');
443
+ }
444
+
445
+ final dir = await _modelsDirectory();
446
+ final oldProjPath = models[index].projectorPath;
447
+ if (oldProjPath != null) {
448
+ final oldProj = File(oldProjPath);
449
+ if (await oldProj.exists() && p.isWithin(dir.path, oldProj.path)) {
450
+ await oldProj.delete();
451
+ }
452
+ }
453
+
454
+ final updated = models[index].copyWith(projectorPath: null);
455
+ models[index] = updated;
456
+ await saveAll(models);
457
+ return updated;
458
+ }
459
+
460
Future<ImportedGgufModelMetadata> importFromHuggingFaceUrl(
461
String sourceUrl, {
462
+ String? projectorUrl,
463
String? token,
464
void Function(int receivedBytes, int totalBytes)? onProgress,
465
CancelToken? cancelToken,
@@ -226,6 +478,8 @@ class ImportedGgufModelRepository {
478
await tempFile.delete();
479
}
480
481
+ File? targetProjectorFile;
482
+
483
try {
484
await _dio.download(
485
normalizedUrl.toString(),
@@ -254,10 +508,60 @@ class ImportedGgufModelRepository {
508
await _validateGgufFile(tempFile);
509
await tempFile.rename(targetPath);
510
511
+ if (projectorUrl != null && projectorUrl.trim().isNotEmpty) {
512
+ final normalizedProjUrl =
513
+ _normalizeHuggingFaceGgufUrl(projectorUrl.trim());
514
+ final projOriginalName = _fileNameFromUri(normalizedProjUrl);
515
+ final projFileName =
516
+ '$id-mmproj-${_sanitizeFileName(projOriginalName)}';
517
+ final projTargetPath = p.join(dir.path, projFileName);
518
+ final projTempPath = '$projTargetPath.part';
519
+ final projTempFile = File(projTempPath);
520
+ targetProjectorFile = File(projTargetPath);
521
+
522
+ if (await projTempFile.exists()) {
523
+ await projTempFile.delete();
524
+ }
525
+
526
+ try {
527
+ await _dio.download(
528
+ normalizedProjUrl.toString(),
529
+ projTempPath,
530
+ cancelToken: cancelToken,
531
+ options: Options(
532
+ headers: {
533
+ if (token != null && token.isNotEmpty)
534
+ HttpHeaders.authorizationHeader: 'Bearer $token',
535
+ },
536
+ followRedirects: true,
537
+ receiveTimeout: const Duration(hours: 12),
538
+ sendTimeout: const Duration(minutes: 5),
539
+ validateStatus: (status) =>
540
+ status != null && status >= 200 && status < 400,
541
+ ),
542
+ );
543
+
544
+ if (!await projTempFile.exists() ||
545
+ await projTempFile.length() <= 0) {
546
+ throw const FileSystemException(
547
+ 'The downloaded projector GGUF file was empty or missing.',
548
+ );
549
+ }
550
+
551
+ await _validateGgufFile(projTempFile);
552
+ await projTempFile.rename(projTargetPath);
553
+ } finally {
554
+ if (await projTempFile.exists()) {
555
+ await projTempFile.delete();
556
+ }
557
+ }
558
+ }
559
+
560
final metadata = ImportedGgufModelMetadata(
561
id: id,
562
name: _displayNameFromFileName(originalName),
563
filePath: targetFile.path,
564
+ projectorPath: targetProjectorFile?.path,
565
fileSizeBytes: await targetFile.length(),
566
importedAt: DateTime.now(),
567
source: OnDeviceImportedSource.huggingFace,
@@ -289,6 +593,12 @@ class ImportedGgufModelRepository {
593
if (await file.exists()) {
594
await file.delete();
595
}
596
+ if (model.projectorPath != null) {
597
+ final projFile = File(model.projectorPath!);
598
+ if (await projFile.exists()) {
599
+ await projFile.delete();
600
+ }
601
+ }
602
} else {
603
kept.add(model);
604
}
lib/features/on_device/data/models/on_device_model.dart
+7
@@ -29,6 +29,7 @@ class OnDeviceModel {
29
final OnDeviceModelRuntime runtime;
30
final OnDeviceModelFormat format;
31
final String? localPath;
32
+ final String? projectorPath;
33
final DateTime? importedAt;
34
final bool isImported;
35
final OnDeviceImportedSource? importedSource;
@@ -55,6 +56,7 @@ class OnDeviceModel {
56
this.runtime = OnDeviceModelRuntime.gemma,
57
this.format = OnDeviceModelFormat.litertlm,
58
this.localPath,
59
+ this.projectorPath,
60
this.importedAt,
61
this.isImported = false,
62
this.importedSource,
@@ -87,6 +89,11 @@ class OnDeviceModel {
89
bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
90
bool get isMlx => runtime == OnDeviceModelRuntime.mlx;
91
92
+ bool get hasProjector =>
93
+ projectorPath != null && projectorPath!.trim().isNotEmpty;
94
+ String? get projectorFileName =>
95
+ hasProjector ? projectorPath!.split('/').last : null;
96
+
97
bool get isImportedFromLocalFile =>
98
importedSource == OnDeviceImportedSource.localFile;
99
lib/features/on_device/data/on_device_llama_service.dart
+60
-3
@@ -1,4 +1,5 @@
1
import 'dart:async';
2
+import 'dart:io';
3
4
import 'package:llamadart/llamadart.dart' as llama;
5
@@ -37,6 +38,16 @@ class OnDeviceLlamaService {
38
String? get currentModelId => _currentModelId;
39
bool get isLoaded => _engine != null && !_isDisposed;
40
41
+ Future<bool> get supportsVision async {
42
+ final engine = _engine;
43
+ if (engine == null) return false;
44
+ try {
45
+ return await engine.supportsVision;
46
+ } catch (_) {
47
+ return false;
48
+ }
49
+ }
50
+
51
Future<void> loadModel(
52
OnDeviceModel model, {
53
int contextLength = 4096,
@@ -73,6 +84,24 @@ class OnDeviceLlamaService {
84
),
85
);
86
87
+ if (model.projectorPath != null && model.projectorPath!.isNotEmpty) {
88
+ final projFile = File(model.projectorPath!);
89
+ if (await projFile.exists()) {
90
+ Log.info('Loading multimodal projector from ${model.projectorPath}');
91
+ try {
92
+ await engine.loadMultimodalProjector(model.projectorPath!);
93
+ final hasVision = await engine.supportsVision;
94
+ Log.info('Multimodal projector loaded (supportsVision=$hasVision)');
95
+ } catch (e) {
96
+ Log.error('Failed to load multimodal projector: $e');
97
+ }
98
+ } else {
99
+ Log.warning(
100
+ 'Configured projector file not found at ${model.projectorPath}',
101
+ );
102
+ }
103
+ }
104
+
105
_engine = engine;
106
_currentModelId = model.id;
107
_currentContextLength = contextLength;
@@ -172,6 +201,7 @@ class OnDeviceLlamaService {
201
202
var text = latestUserMessage.content;
203
final paths = latestUserMessage.attachmentPaths;
204
+ final imagePaths = <String>[];
205
if (paths != null && paths.isNotEmpty) {
206
for (final path in paths) {
207
if (AttachmentHelpers.isDocumentPath(path)) {
@@ -183,22 +213,49 @@ class OnDeviceLlamaService {
213
docText,
214
);
215
}
216
+ } else if (AttachmentHelpers.isImagePath(path)) {
217
+ imagePaths.add(path);
218
}
219
}
220
}
221
+
222
+ if (imagePaths.isNotEmpty) {
223
+ final supportsVision = await engine.supportsVision;
224
+ if (!supportsVision) {
225
+ yield const ChatResponse(
226
+ type: ChatResponseType.error,
227
+ content:
228
+ 'The active model does not support image attachments. '
229
+ 'Please attach a vision projector (mmproj) to this model or select a vision-supported model.',
230
+ );
231
+ yield const ChatResponse(type: ChatResponseType.done);
232
+ return;
233
+ }
234
+ }
235
+
236
text = text.trim();
190
- if (text.isEmpty) {
237
+ if (text.isEmpty && imagePaths.isEmpty) {
238
yield const ChatResponse(
239
type: ChatResponseType.error,
193
- content: 'GGUF chat requires a text prompt.',
240
+ content: 'GGUF chat requires a text prompt or image attachment.',
241
);
242
yield const ChatResponse(type: ChatResponseType.done);
243
return;
244
}
245
246
+ final contentParts = <llama.LlamaContentPart>[];
247
+ if (text.isNotEmpty) {
248
+ contentParts.add(llama.LlamaTextContent(text));
249
+ } else {
250
+ contentParts.add(const llama.LlamaTextContent('Describe this image.'));
251
+ }
252
+ for (final imagePath in imagePaths) {
253
+ contentParts.add(llama.LlamaImageContent(path: imagePath));
254
+ }
255
+
256
try {
257
await for (final chunk in _session!.create(
201
- [llama.LlamaTextContent(text)],
258
+ contentParts,
259
enableThinking:
260
reasoningPreference?.call(modelId) ??
261
params.reasoningEnabled ??
lib/features/on_device/providers/on_device_providers.dart
+41
-2
@@ -225,8 +225,14 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
225
return _repository.load().map((model) => model.toOnDeviceModel()).toList();
226
}
227
228
- Future<OnDeviceModel> importModel(String sourcePath) async {
229
- final metadata = await _repository.importFromPath(sourcePath);
228
+ Future<OnDeviceModel> importModel(
229
+ String sourcePath, {
230
+ String? projectorPath,
231
+ }) async {
232
+ final metadata = await _repository.importFromPath(
233
+ sourcePath,
234
+ projectorPath: projectorPath,
235
+ );
236
final model = metadata.toOnDeviceModel();
237
if (ref.mounted) {
238
state = [...state, model];
@@ -234,14 +240,47 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
240
return model;
241
}
242
243
+ Future<List<OnDeviceModel>> importModels(List<String> paths) async {
244
+ final metadatas = await _repository.importFromPaths(paths);
245
+ final models = metadatas.map((m) => m.toOnDeviceModel()).toList();
246
+ if (ref.mounted) {
247
+ state = [...state, ...models];
248
+ }
249
+ return models;
250
+ }
251
+
252
+ Future<OnDeviceModel> attachProjector(
253
+ String modelId,
254
+ String projectorSourcePath,
255
+ ) async {
256
+ final metadata = await _repository.attachProjector(
257
+ modelId,
258
+ projectorSourcePath,
259
+ );
260
+ if (ref.mounted) {
261
+ state = _repository.load().map((m) => m.toOnDeviceModel()).toList();
262
+ }
263
+ return metadata.toOnDeviceModel();
264
+ }
265
+
266
+ Future<OnDeviceModel> removeProjector(String modelId) async {
267
+ final metadata = await _repository.removeProjector(modelId);
268
+ if (ref.mounted) {
269
+ state = _repository.load().map((m) => m.toOnDeviceModel()).toList();
270
+ }
271
+ return metadata.toOnDeviceModel();
272
+ }
273
+
274
Future<OnDeviceModel> importModelFromHuggingFaceUrl(
275
String sourceUrl, {
276
+ String? projectorUrl,
277
String? huggingFaceToken,
278
void Function(int receivedBytes, int totalBytes)? onProgress,
279
CancelToken? cancelToken,
280
}) async {
281
final metadata = await _repository.importFromHuggingFaceUrl(
282
sourceUrl,
283
+ projectorUrl: projectorUrl,
284
token: huggingFaceToken,
285
onProgress: onProgress,
286
cancelToken: cancelToken,
lib/features/on_device/views/components/imported_model_projector_setting.dart
new
+190
@@ -0,0 +1,190 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+
5
+import '../../../../core/utils/safe_file_picker.dart';
6
+import '../../../../l10n/app_localizations.dart';
7
+import '../../data/models/on_device_model.dart';
8
+import '../../providers/on_device_providers.dart';
9
+
10
+class ImportedModelProjectorSetting extends ConsumerStatefulWidget {
11
+ const ImportedModelProjectorSetting({super.key, required this.model});
12
+
13
+ final OnDeviceModel model;
14
+
15
+ @override
16
+ ConsumerState<ImportedModelProjectorSetting> createState() =>
17
+ _ImportedModelProjectorSettingState();
18
+}
19
+
20
+class _ImportedModelProjectorSettingState
21
+ extends ConsumerState<ImportedModelProjectorSetting> {
22
+ bool _busy = false;
23
+
24
+ Future<void> _attachProjector() async {
25
+ final l10n = AppLocalizations.of(context)!;
26
+ final messenger = ScaffoldMessenger.maybeOf(context);
27
+
28
+ try {
29
+ final result = await SafeFilePicker.pickFiles(
30
+ type: FileType.custom,
31
+ allowedExtensions: const ['gguf'],
32
+ );
33
+ if (result == null || result.files.isEmpty) return;
34
+
35
+ final path = result.files.single.path;
36
+ if (path == null || !path.toLowerCase().endsWith('.gguf')) {
37
+ messenger?.showSnackBar(
38
+ SnackBar(content: Text(l10n.gguf_only_supported)),
39
+ );
40
+ return;
41
+ }
42
+
43
+ setState(() => _busy = true);
44
+ await ref
45
+ .read(importedGgufModelsProvider.notifier)
46
+ .attachProjector(widget.model.id, path);
47
+
48
+ if (!mounted) return;
49
+ messenger?.showSnackBar(
50
+ SnackBar(content: Text(l10n.gguf_projector_attached)),
51
+ );
52
+ } catch (e) {
53
+ if (!mounted) return;
54
+ messenger?.showSnackBar(
55
+ SnackBar(content: Text(e.toString())),
56
+ );
57
+ } finally {
58
+ if (mounted) setState(() => _busy = false);
59
+ }
60
+ }
61
+
62
+ Future<void> _removeProjector() async {
63
+ final l10n = AppLocalizations.of(context)!;
64
+ final messenger = ScaffoldMessenger.maybeOf(context);
65
+
66
+ setState(() => _busy = true);
67
+ try {
68
+ await ref
69
+ .read(importedGgufModelsProvider.notifier)
70
+ .removeProjector(widget.model.id);
71
+
72
+ if (!mounted) return;
73
+ messenger?.showSnackBar(
74
+ SnackBar(content: Text(l10n.gguf_projector_removed)),
75
+ );
76
+ } catch (e) {
77
+ if (!mounted) return;
78
+ messenger?.showSnackBar(
79
+ SnackBar(content: Text(e.toString())),
80
+ );
81
+ } finally {
82
+ if (mounted) setState(() => _busy = false);
83
+ }
84
+ }
85
+
86
+ @override
87
+ Widget build(BuildContext context) {
88
+ final l10n = AppLocalizations.of(context)!;
89
+ final theme = Theme.of(context);
90
+ final hasProjector = widget.model.hasProjector;
91
+
92
+ return Padding(
93
+ padding: const EdgeInsets.only(top: 8),
94
+ child: Container(
95
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
96
+ decoration: BoxDecoration(
97
+ color: theme.colorScheme.surfaceContainerHighest.withValues(
98
+ alpha: 0.25,
99
+ ),
100
+ borderRadius: BorderRadius.circular(10),
101
+ border: Border.all(
102
+ color: theme.colorScheme.outline.withValues(alpha: 0.15),
103
+ ),
104
+ ),
105
+ child: Row(
106
+ children: [
107
+ HugeIcon(
108
+ icon: HugeIcons.strokeRoundedImage01,
109
+ size: 16,
110
+ color: hasProjector
111
+ ? theme.colorScheme.primary
112
+ : theme.colorScheme.onSurfaceVariant,
113
+ ),
114
+ const SizedBox(width: 8),
115
+ Expanded(
116
+ child: Column(
117
+ crossAxisAlignment: CrossAxisAlignment.start,
118
+ mainAxisSize: MainAxisSize.min,
119
+ children: [
120
+ Text(
121
+ l10n.gguf_vision_projector,
122
+ style: theme.textTheme.labelMedium?.copyWith(
123
+ fontWeight: FontWeight.w600,
124
+ ),
125
+ ),
126
+ if (hasProjector && widget.model.projectorFileName != null)
127
+ Text(
128
+ widget.model.projectorFileName!,
129
+ style: theme.textTheme.bodySmall?.copyWith(
130
+ color: theme.colorScheme.onSurfaceVariant,
131
+ fontSize: 11,
132
+ ),
133
+ maxLines: 1,
134
+ overflow: TextOverflow.ellipsis,
135
+ ),
136
+ ],
137
+ ),
138
+ ),
139
+ if (_busy)
140
+ const SizedBox(
141
+ width: 20,
142
+ height: 20,
143
+ child: CircularProgressIndicator(strokeWidth: 2),
144
+ )
145
+ else if (hasProjector) ...[
146
+ IconButton(
147
+ icon: const HugeIcon(
148
+ icon: HugeIcons.strokeRoundedEdit02,
149
+ size: 16,
150
+ ),
151
+ tooltip: l10n.gguf_change_projector,
152
+ visualDensity: VisualDensity.compact,
153
+ padding: EdgeInsets.zero,
154
+ constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
155
+ onPressed: _attachProjector,
156
+ ),
157
+ IconButton(
158
+ icon: HugeIcon(
159
+ icon: HugeIcons.strokeRoundedDelete02,
160
+ size: 16,
161
+ color: theme.colorScheme.error,
162
+ ),
163
+ tooltip: l10n.gguf_remove_projector,
164
+ visualDensity: VisualDensity.compact,
165
+ padding: EdgeInsets.zero,
166
+ constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
167
+ onPressed: _removeProjector,
168
+ ),
169
+ ] else
170
+ TextButton.icon(
171
+ onPressed: _attachProjector,
172
+ icon: const HugeIcon(
173
+ icon: HugeIcons.strokeRoundedImageAdd01,
174
+ size: 14,
175
+ ),
176
+ label: Text(
177
+ l10n.gguf_attach_projector,
178
+ style: theme.textTheme.labelSmall,
179
+ ),
180
+ style: TextButton.styleFrom(
181
+ visualDensity: VisualDensity.compact,
182
+ padding: const EdgeInsets.symmetric(horizontal: 8),
183
+ ),
184
+ ),
185
+ ],
186
+ ),
187
+ ),
188
+ );
189
+ }
190
+}
lib/features/on_device/views/model_manager_screen.dart
+66
-11
@@ -9,6 +9,8 @@ import 'package:localmind/core/models/enums.dart';
9
import 'package:localmind/core/providers/app_providers.dart';
10
import 'package:localmind/core/utils/safe_file_picker.dart';
11
import 'package:localmind/l10n/app_localizations.dart';
12
+import 'package:path/path.dart' as p;
13
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
14
import 'package:localmind/features/on_device/data/models/on_device_model.dart';
15
import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
16
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
@@ -130,11 +132,17 @@ class _OnDeviceModelManagerScreenState
132
final result = await SafeFilePicker.pickFiles(
133
type: FileType.custom,
134
allowedExtensions: const ['gguf'],
135
+ allowMultiple: true,
136
);
137
if (result == null || result.files.isEmpty) return;
138
136
- final path = result.files.single.path;
137
- if (path == null || !path.toLowerCase().endsWith('.gguf')) {
139
+ final paths = result.files
140
+ .map((f) => f.path)
141
+ .whereType<String>()
142
+ .where((p) => p.toLowerCase().endsWith('.gguf'))
143
+ .toList();
144
+
145
+ if (paths.isEmpty) {
146
if (!mounted) return;
147
messenger?.showSnackBar(
148
SnackBar(content: Text(l10n.gguf_only_supported)),
@@ -142,15 +150,36 @@ class _OnDeviceModelManagerScreenState
150
return;
151
}
152
145
- final model = await ref
146
- .read(importedGgufModelsProvider.notifier)
147
- .importModel(path);
148
- if (!mounted) return;
149
- messenger?.showSnackBar(
150
- SnackBar(
151
- content: Text('${model.name} ${l10n.gguf_imported_from_local_file}'),
152
- ),
153
- );
153
+ if (paths.length == 1) {
154
+ final path = paths.single;
155
+ if (ImportedGgufModelRepository.isProjectorFileName(p.basename(path))) {
156
+ if (!mounted) return;
157
+ messenger?.showSnackBar(
158
+ SnackBar(content: Text(l10n.gguf_is_projector_file)),
159
+ );
160
+ return;
161
+ }
162
+
163
+ final model = await ref
164
+ .read(importedGgufModelsProvider.notifier)
165
+ .importModel(path);
166
+ if (!mounted) return;
167
+ final msg = model.hasProjector
168
+ ? '${model.name} ${l10n.gguf_imported_from_local_file} (${l10n.gguf_projector_auto_detected(model.projectorFileName ?? "")})'
169
+ : '${model.name} ${l10n.gguf_imported_from_local_file}';
170
+ messenger?.showSnackBar(SnackBar(content: Text(msg)));
171
+ } else {
172
+ final models = await ref
173
+ .read(importedGgufModelsProvider.notifier)
174
+ .importModels(paths);
175
+ if (!mounted) return;
176
+ final names = models.map((m) => m.name).join(', ');
177
+ messenger?.showSnackBar(
178
+ SnackBar(
179
+ content: Text('$names ${l10n.gguf_imported_from_local_file}'),
180
+ ),
181
+ );
182
+ }
183
} catch (e) {
184
if (!mounted) return;
185
messenger?.showSnackBar(
@@ -610,6 +639,7 @@ class _HuggingFaceGgufImportDialogState
639
);
640
641
final TextEditingController _urlController = TextEditingController();
642
+ final TextEditingController _projectorUrlController = TextEditingController();
643
CancelToken? _cancelToken;
644
bool _isImporting = false;
645
String? _error;
@@ -620,6 +650,7 @@ class _HuggingFaceGgufImportDialogState
650
void dispose() {
651
_cancelToken?.cancel();
652
_urlController.dispose();
653
+ _projectorUrlController.dispose();
654
super.dispose();
655
}
656
@@ -748,6 +779,27 @@ class _HuggingFaceGgufImportDialogState
779
_ImportPreviewCard(preview: preview),
780
],
781
const SizedBox(height: 12),
782
+ TextField(
783
+ controller: _projectorUrlController,
784
+ enabled: !_isImporting,
785
+ maxLines: 1,
786
+ keyboardType: TextInputType.url,
787
+ textInputAction: TextInputAction.done,
788
+ decoration: InputDecoration(
789
+ labelText: l10n.gguf_projector_url_label,
790
+ hintText: l10n.gguf_projector_url_hint,
791
+ border: const OutlineInputBorder(),
792
+ prefixIcon: const Center(
793
+ widthFactor: 1.0,
794
+ heightFactor: 1.0,
795
+ child: HugeIcon(
796
+ icon: HugeIcons.strokeRoundedImage01,
797
+ size: 20,
798
+ ),
799
+ ),
800
+ ),
801
+ ),
802
+ const SizedBox(height: 12),
803
Container(
804
width: double.infinity,
805
padding: const EdgeInsets.all(12),
@@ -944,6 +996,7 @@ class _HuggingFaceGgufImportDialogState
996
void _clearInput() {
997
setState(() {
998
_urlController.clear();
999
+ _projectorUrlController.clear();
1000
_error = null;
1001
});
1002
}
@@ -999,10 +1052,12 @@ class _HuggingFaceGgufImportDialogState
1052
final huggingFaceToken = ref.read(
1053
settingsProvider.select((settings) => settings.huggingFaceToken),
1054
);
1055
+ final projectorUrl = _projectorUrlController.text.trim();
1056
final model = await ref
1057
.read(importedGgufModelsProvider.notifier)
1058
.importModelFromHuggingFaceUrl(
1059
sourceUrl,
1060
+ projectorUrl: projectorUrl.isNotEmpty ? projectorUrl : null,
1061
huggingFaceToken: huggingFaceToken,
1062
cancelToken: _cancelToken,
1063
onProgress: (receivedBytes, totalBytes) {
lib/l10n/app_en.arb
+18
@@ -2822,6 +2822,24 @@
2822
"gguf_imported_section_label": "Imported GGUF",
2823
"gguf_already_available": "Already available on this device",
2824
"gguf_curated_models_short": "Curated on-device models",
2825
+ "gguf_vision_projector": "Vision Projector",
2826
+ "gguf_attach_projector": "Attach Vision Projector",
2827
+ "gguf_change_projector": "Change Vision Projector",
2828
+ "gguf_remove_projector": "Remove Projector",
2829
+ "gguf_projector_attached": "Vision projector attached successfully",
2830
+ "gguf_projector_removed": "Vision projector removed",
2831
+ "gguf_projector_auto_detected": "Auto-detected and linked vision projector: {name}",
2832
+ "@gguf_projector_auto_detected": {
2833
+ "placeholders": {
2834
+ "name": {
2835
+ "type": "String"
2836
+ }
2837
+ }
2838
+ },
2839
+ "gguf_is_projector_file": "The selected file is a vision projector (mmproj), not a standalone model.",
2840
+ "gguf_projector_url_label": "Vision Projector URL (optional mmproj)",
2841
+ "gguf_projector_url_hint": "https://huggingface.co/.../mmproj-...gguf",
2842
+ "gguf_vision_not_supported_error": "The active model does not support image attachments. Please attach a vision projector (mmproj) or select a vision-supported model.",
2843
"execute_tool_title": "Execute Tool",
2844
"execute_tool_request_desc": "The model is requesting to execute the following tool:",
2845
"reject": "Reject",
lib/l10n/app_localizations.dart
+66
@@ -4083,6 +4083,72 @@ abstract class AppLocalizations {
4083
/// **'Curated on-device models'**
4084
String get gguf_curated_models_short;
4085
4086
+ /// No description provided for @gguf_vision_projector.
4087
+ ///
4088
+ /// In en, this message translates to:
4089
+ /// **'Vision Projector'**
4090
+ String get gguf_vision_projector;
4091
+
4092
+ /// No description provided for @gguf_attach_projector.
4093
+ ///
4094
+ /// In en, this message translates to:
4095
+ /// **'Attach Vision Projector'**
4096
+ String get gguf_attach_projector;
4097
+
4098
+ /// No description provided for @gguf_change_projector.
4099
+ ///
4100
+ /// In en, this message translates to:
4101
+ /// **'Change Vision Projector'**
4102
+ String get gguf_change_projector;
4103
+
4104
+ /// No description provided for @gguf_remove_projector.
4105
+ ///
4106
+ /// In en, this message translates to:
4107
+ /// **'Remove Projector'**
4108
+ String get gguf_remove_projector;
4109
+
4110
+ /// No description provided for @gguf_projector_attached.
4111
+ ///
4112
+ /// In en, this message translates to:
4113
+ /// **'Vision projector attached successfully'**
4114
+ String get gguf_projector_attached;
4115
+
4116
+ /// No description provided for @gguf_projector_removed.
4117
+ ///
4118
+ /// In en, this message translates to:
4119
+ /// **'Vision projector removed'**
4120
+ String get gguf_projector_removed;
4121
+
4122
+ /// No description provided for @gguf_projector_auto_detected.
4123
+ ///
4124
+ /// In en, this message translates to:
4125
+ /// **'Auto-detected and linked vision projector: {name}'**
4126
+ String gguf_projector_auto_detected(String name);
4127
+
4128
+ /// No description provided for @gguf_is_projector_file.
4129
+ ///
4130
+ /// In en, this message translates to:
4131
+ /// **'The selected file is a vision projector (mmproj), not a standalone model.'**
4132
+ String get gguf_is_projector_file;
4133
+
4134
+ /// No description provided for @gguf_projector_url_label.
4135
+ ///
4136
+ /// In en, this message translates to:
4137
+ /// **'Vision Projector URL (optional mmproj)'**
4138
+ String get gguf_projector_url_label;
4139
+
4140
+ /// No description provided for @gguf_projector_url_hint.
4141
+ ///
4142
+ /// In en, this message translates to:
4143
+ /// **'https://huggingface.co/.../mmproj-...gguf'**
4144
+ String get gguf_projector_url_hint;
4145
+
4146
+ /// No description provided for @gguf_vision_not_supported_error.
4147
+ ///
4148
+ /// In en, this message translates to:
4149
+ /// **'The active model does not support image attachments. Please attach a vision projector (mmproj) or select a vision-supported model.'**
4150
+ String get gguf_vision_not_supported_error;
4151
+
4152
/// No description provided for @execute_tool_title.
4153
///
4154
/// In en, this message translates to:
lib/l10n/app_localizations_en.dart
+40
@@ -2165,6 +2165,46 @@ class AppLocalizationsEn extends AppLocalizations {
2165
@override
2166
String get gguf_curated_models_short => 'Curated on-device models';
2167
2168
+ @override
2169
+ String get gguf_vision_projector => 'Vision Projector';
2170
+
2171
+ @override
2172
+ String get gguf_attach_projector => 'Attach Vision Projector';
2173
+
2174
+ @override
2175
+ String get gguf_change_projector => 'Change Vision Projector';
2176
+
2177
+ @override
2178
+ String get gguf_remove_projector => 'Remove Projector';
2179
+
2180
+ @override
2181
+ String get gguf_projector_attached =>
2182
+ 'Vision projector attached successfully';
2183
+
2184
+ @override
2185
+ String get gguf_projector_removed => 'Vision projector removed';
2186
+
2187
+ @override
2188
+ String gguf_projector_auto_detected(String name) {
2189
+ return 'Auto-detected and linked vision projector: $name';
2190
+ }
2191
+
2192
+ @override
2193
+ String get gguf_is_projector_file =>
2194
+ 'The selected file is a vision projector (mmproj), not a standalone model.';
2195
+
2196
+ @override
2197
+ String get gguf_projector_url_label =>
2198
+ 'Vision Projector URL (optional mmproj)';
2199
+
2200
+ @override
2201
+ String get gguf_projector_url_hint =>
2202
+ 'https://huggingface.co/.../mmproj-...gguf';
2203
+
2204
+ @override
2205
+ String get gguf_vision_not_supported_error =>
2206
+ 'The active model does not support image attachments. Please attach a vision projector (mmproj) or select a vision-supported model.';
2207
+
2208
@override
2209
String get execute_tool_title => 'Execute Tool';
2210
test/features/on_device/imported_gguf_model_repository_test.dart
+104
@@ -192,17 +192,121 @@ void main() {
192
throwsA(isA<FormatException>()),
193
);
194
});
195
+ test('serializes and converts to a llama.cpp on-device model with projector', () {
196
+ final importedAt = DateTime.utc(2026, 6, 21, 12);
197
+ final metadata = ImportedGgufModelMetadata(
198
+ id: 'gguf-vlm',
199
+ name: 'LLaVA 1.6',
200
+ filePath: '/tmp/llava.gguf',
201
+ projectorPath: '/tmp/mmproj-llava.gguf',
202
+ fileSizeBytes: 1234,
203
+ importedAt: importedAt,
204
+ source: OnDeviceImportedSource.localFile,
205
+ );
206
+
207
+ final restored = ImportedGgufModelMetadata.fromJson(
208
+ json.decode(json.encode(metadata.toJson())) as Map<String, dynamic>,
209
+ );
210
+ final model = restored.toOnDeviceModel();
211
+
212
+ expect(restored.projectorPath, '/tmp/mmproj-llava.gguf');
213
+ expect(restored.projectorFileName, 'mmproj-llava.gguf');
214
+ expect(model.supportsVision, isTrue);
215
+ expect(model.hasProjector, isTrue);
216
+ expect(model.projectorPath, '/tmp/mmproj-llava.gguf');
217
+ expect(model.projectorFileName, 'mmproj-llava.gguf');
218
+ });
219
+
220
+ test('isProjectorFileName correctly identifies vision projector files', () {
221
+ expect(
222
+ ImportedGgufModelRepository.isProjectorFileName('mmproj-model-f16.gguf'),
223
+ isTrue,
224
+ );
225
+ expect(
226
+ ImportedGgufModelRepository.isProjectorFileName('qwen2-vl-mmproj.gguf'),
227
+ isTrue,
228
+ );
229
+ expect(
230
+ ImportedGgufModelRepository.isProjectorFileName('vision_projector.gguf'),
231
+ isTrue,
232
+ );
233
+ expect(
234
+ ImportedGgufModelRepository.isProjectorFileName('qwen2.5-7b-instruct.gguf'),
235
+ isFalse,
236
+ );
237
+ });
238
+
239
+ test('delete removes both model and projector file', () async {
240
+ final modelFile = File('${tempDir.path}/vlm.gguf');
241
+ final projFile = File('${tempDir.path}/mmproj-vlm.gguf');
242
+ await modelFile.writeAsString('model');
243
+ await projFile.writeAsString('projector');
244
+
245
+ final repository = await createRepository();
246
+ await repository.saveAll([
247
+ _metadata(
248
+ id: 'gguf-vlm-delete',
249
+ filePath: modelFile.path,
250
+ projectorPath: projFile.path,
251
+ ),
252
+ ]);
253
+
254
+ await repository.delete('gguf-vlm-delete');
255
+
256
+ expect(await modelFile.exists(), isFalse);
257
+ expect(await projFile.exists(), isFalse);
258
+ expect(repository.load(), isEmpty);
259
+ });
260
+
261
+ test('attachProjector and removeProjector update model metadata', () async {
262
+ final modelFile = File('${tempDir.path}/model.gguf');
263
+ final projFile = File('${tempDir.path}/mmproj-test.gguf');
264
+ // Valid GGUF magic bytes: 'G', 'G', 'U', 'F'
265
+ const ggufBytes = [0x47, 0x47, 0x55, 0x46, 0x00, 0x00];
266
+ await modelFile.writeAsBytes(ggufBytes);
267
+ await projFile.writeAsBytes(ggufBytes);
268
+
269
+ final repository = await createRepository();
270
+ await repository.saveAll([
271
+ _metadata(id: 'gguf-attach-test', filePath: modelFile.path),
272
+ ]);
273
+
274
+ final attached = await repository.attachProjector(
275
+ 'gguf-attach-test',
276
+ projFile.path,
277
+ );
278
+
279
+ expect(attached.projectorPath, isNotNull);
280
+ expect(attached.toOnDeviceModel().supportsVision, isTrue);
281
+
282
+ final removed = await repository.removeProjector('gguf-attach-test');
283
+ expect(removed.projectorPath, isNull);
284
+ expect(removed.toOnDeviceModel().supportsVision, isFalse);
285
+ });
286
+
287
+ test('rejects standalone mmproj file import without model', () async {
288
+ final projFile = File('${tempDir.path}/mmproj-only.gguf');
289
+ await projFile.writeAsBytes([0x47, 0x47, 0x55, 0x46]);
290
+
291
+ final repository = await createRepository();
292
+ expect(
293
+ () => repository.importFromPath(projFile.path),
294
+ throwsA(isA<FormatException>()),
295
+ );
296
+ });
297
});
298
}
299
300
ImportedGgufModelMetadata _metadata({
301
required String id,
302
required String filePath,
303
+ String? projectorPath,
304
}) {
305
return ImportedGgufModelMetadata(
306
id: id,
307
name: 'Test GGUF',
308
filePath: filePath,
309
+ projectorPath: projectorPath,
310
fileSizeBytes: 42,
311
importedAt: DateTime.utc(2026, 6, 21),
312
source: OnDeviceImportedSource.localFile,
test/features/on_device/on_device_llama_reasoning_test.dart
+80
@@ -10,6 +10,11 @@ import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
10
11
class RecordingEngine implements llama.LlamaEngine {
12
final thinking = <bool>[];
13
+ final loadedProjectors = <String>[];
14
+ bool visionSupported = false;
15
+
16
+ @override
17
+ Future<bool> get supportsVision => Future.value(visionSupported);
18
19
@override
20
dynamic noSuchMethod(Invocation invocation) {
@@ -17,6 +22,11 @@ class RecordingEngine implements llama.LlamaEngine {
22
case #loadModel:
23
case #dispose:
24
return Future<void>.value();
25
+ case #loadMultimodalProjector:
26
+ loadedProjectors.add(invocation.positionalArguments[0] as String);
27
+ return Future<void>.value();
28
+ case #supportsVision:
29
+ return Future.value(visionSupported);
30
case #chatTemplate:
31
return Future.value(
32
const llama.LlamaChatTemplateResult(prompt: 'test'),
@@ -99,4 +109,74 @@ void main() {
109
expect(engine.thinking, [false, true, false, true]);
110
},
111
);
112
+
113
+ test('loads multimodal projector when model has projectorPath', () async {
114
+ final engine = RecordingEngine();
115
+ final service = OnDeviceLlamaService(
116
+ engineFactory: () => engine,
117
+ );
118
+ final model = ImportedGgufModelMetadata(
119
+ id: 'vision-model',
120
+ name: 'Vision Model',
121
+ filePath: '/tmp/model.gguf',
122
+ projectorPath: '/tmp/model-mmproj.gguf',
123
+ fileSizeBytes: 4,
124
+ importedAt: DateTime.utc(2026),
125
+ source: OnDeviceImportedSource.localFile,
126
+ ).toOnDeviceModel();
127
+
128
+ await service.loadModel(model);
129
+
130
+ expect(engine.loadedProjectors, ['/tmp/model-mmproj.gguf']);
131
+ expect(model.supportsVision, isTrue);
132
+ expect(model.hasProjector, isTrue);
133
+ expect(model.projectorFileName, 'model-mmproj.gguf');
134
+ });
135
+
136
+ test(
137
+ 'returns error when message has image attachments but model does not support vision',
138
+ () async {
139
+ final engine = RecordingEngine()..visionSupported = false;
140
+ final service = OnDeviceLlamaService(
141
+ engineFactory: () => engine,
142
+ );
143
+ final model = ImportedGgufModelMetadata(
144
+ id: 'text-model',
145
+ name: 'Text Model',
146
+ filePath: '/tmp/model.gguf',
147
+ fileSizeBytes: 4,
148
+ importedAt: DateTime.utc(2026),
149
+ source: OnDeviceImportedSource.localFile,
150
+ ).toOnDeviceModel();
151
+
152
+ await service.loadModel(model);
153
+
154
+ final messages = [
155
+ Message(
156
+ id: 'u',
157
+ conversationId: 'c',
158
+ role: MessageRole.user,
159
+ content: 'What is in this image?',
160
+ attachmentPaths: ['/tmp/image.png'],
161
+ createdAt: DateTime.utc(2026),
162
+ ),
163
+ ];
164
+
165
+ final responses = await service
166
+ .sendMessage(
167
+ modelId: 'text-model',
168
+ messages: messages,
169
+ params: ChatParameters.defaults(),
170
+ )
171
+ .toList();
172
+
173
+ final errors =
174
+ responses.where((r) => r.type == ChatResponseType.error).toList();
175
+ expect(errors, isNotEmpty);
176
+ expect(
177
+ errors.first.content,
178
+ contains('does not support image attachments'),
179
+ );
180
+ },
181
+ );
182
}