@tduncan / Cortana / commits / c02556b

feat(on-device): add VLM and vision projector (mmproj) support for imported GGUF models

abdulmominsakib committed Sep 21, 2026 at 08:31 UTC c02556b9422339bfe32961223ab6647415dd53df
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 }