@tduncan / Cortana / commits / 96c7b1c

fix(on-device): harden vision sessions and projector imports

Reject images on text-only models instead of silently dropping them, keep historical images out of the current prompt, and rebuild sessions when attachment or projector state changes. Make projector selection unambiguous and file updates failure-safe so invalid imports cannot leave broken metadata or orphaned files.

abdulmominsakib committed Sep 21, 2026 at 11:54 UTC 96c7b1c71b9a9574ad01498013f28221d0ba29ba
10 files changed +701 -170
lib/features/chat/data/chat_api_error.dart
+3
@@ -4,6 +4,9 @@ import 'dart:convert';
4 class ChatApiError {
5 const ChatApiError({required this.message, this.type, this.code, this.param});
6
7 + static const onDeviceVisionNotSupportedCode =
8 + 'on_device_vision_not_supported';
9 +
10 final String message;
11 final String? type;
12 final String? code;
lib/features/chat/views/components/chat_bubble/chat_error_display.dart
+7 -1
@@ -1,5 +1,6 @@
1 import 'package:flutter/material.dart';
2 import 'package:localmind/features/chat/data/chat_api_error.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4
5 class ChatErrorDisplay extends StatelessWidget {
6 const ChatErrorDisplay({super.key, required this.errorMessage});
@@ -20,6 +21,11 @@ class ChatErrorDisplay extends StatelessWidget {
21 );
22 }
23
24 + final displayMessage =
25 + parsed.code == ChatApiError.onDeviceVisionNotSupportedCode
26 + ? AppLocalizations.of(context)!.gguf_vision_not_supported_error
27 + : parsed.message;
28 +
29 final metaParts = <String>[];
30 if (parsed.type != null && parsed.type!.isNotEmpty) {
31 metaParts.add('type: ${parsed.type}');
@@ -35,7 +41,7 @@ class ChatErrorDisplay extends StatelessWidget {
41 crossAxisAlignment: CrossAxisAlignment.start,
42 children: [
43 Text(
38 - parsed.message,
44 + displayMessage,
45 style: TextStyle(
46 color: Colors.red[400],
47 fontSize: 12,
lib/features/on_device/data/imported_gguf_model_repository.dart
+145 -68
@@ -37,8 +37,8 @@ class ImportedGgufModelMetadata {
37 String get fileName => p.basename(filePath);
38 String? get projectorFileName =>
39 projectorPath != null && projectorPath!.isNotEmpty
40 - ? p.basename(projectorPath!)
41 - : null;
40 + ? p.basename(projectorPath!)
41 + : null;
42
43 String get sourceLabel {
44 switch (source) {
@@ -157,9 +157,14 @@ class ImportedGgufModelRepository {
157 final SharedPreferences _prefs;
158 final Dio _dio;
159 final Random _random;
160 + final Future<Directory> Function()? modelsDirectoryProvider;
161
161 - ImportedGgufModelRepository(this._prefs, this._dio, {Random? random})
162 - : _random = random ?? Random.secure();
162 + ImportedGgufModelRepository(
163 + this._prefs,
164 + this._dio, {
165 + Random? random,
166 + this.modelsDirectoryProvider,
167 + }) : _random = random ?? Random.secure();
168
169 List<ImportedGgufModelMetadata> load() {
170 final encoded = _prefs.getString(_storageKey);
@@ -256,26 +261,18 @@ class ImportedGgufModelRepository {
261 final candidates = parentDir
262 .listSync()
263 .whereType<File>()
259 - .where((f) =>
260 - f.path.toLowerCase().endsWith('.gguf') &&
261 - isProjectorFileName(p.basename(f.path)))
264 + .where(
265 + (f) =>
266 + f.path.toLowerCase().endsWith('.gguf') &&
267 + isProjectorFileName(p.basename(f.path)),
268 + )
269 + .map((file) => file.path)
270 .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 - }
271 + resolvedProjectorPath = _uniqueProjectorForModel(
272 + sourcePath,
273 + candidates,
274 + allowSingleFallback: true,
275 + );
276 }
277 } catch (_) {}
278 }
@@ -349,31 +346,41 @@ class ImportedGgufModelRepository {
346 );
347 }
348
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;
349 + final projectorByModel = <String, String>{};
350 + for (final projector in projectors) {
351 + final matchingModels = models
352 + .where((model) => _pathsShareProjectorTokens(model, projector))
353 + .toList(growable: false);
354 + final String matchedModel;
355 + if (matchingModels.length == 1) {
356 + matchedModel = matchingModels.single;
357 + } else if (models.length == 1 && projectors.length == 1) {
358 + matchedModel = models.single;
359 + } else {
360 + throw FormatException(
361 + matchingModels.isEmpty
362 + ? 'Could not match ${p.basename(projector)} to a selected model. '
363 + 'Import the model first, then attach the projector manually.'
364 + : '${p.basename(projector)} matches more than one selected model. '
365 + 'Import the models separately and attach the projector manually.',
366 + );
367 + }
368 + if (projectorByModel.containsKey(matchedModel)) {
369 + throw FormatException(
370 + 'More than one projector was selected for ${p.basename(matchedModel)}. '
371 + 'Choose one projector and attach any replacement later.',
372 + );
373 + }
374 + projectorByModel[matchedModel] = projector;
375 }
376
377 + final results = <ImportedGgufModelMetadata>[];
378 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 -
379 results.add(
376 - await importFromPath(modelPath, projectorPath: matchingProjector),
380 + await importFromPath(
381 + modelPath,
382 + projectorPath: projectorByModel[modelPath],
383 + ),
384 );
385 }
386
@@ -386,9 +393,7 @@ class ImportedGgufModelRepository {
393 ) async {
394 final projSource = File(projectorSourcePath);
395 if (!projectorSourcePath.toLowerCase().endsWith('.gguf')) {
389 - throw const FormatException(
390 - 'Only GGUF projector files are supported.',
391 - );
396 + throw const FormatException('Only GGUF projector files are supported.');
397 }
398 if (!await projSource.exists()) {
399 throw FileSystemException(
@@ -406,32 +411,46 @@ class ImportedGgufModelRepository {
411 final dir = await _modelsDirectory();
412 final projOriginalName = p.basename(projectorSourcePath);
413 final projFileName =
409 - '$modelId-mmproj-${_sanitizeFileName(projOriginalName)}';
414 + '$modelId-mmproj-${_createId(projOriginalName)}-'
415 + '${_sanitizeFileName(projOriginalName)}';
416 final targetProjector = File(p.join(dir.path, projFileName));
417 + final stagedProjector = File('${targetProjector.path}.part');
418 + var metadataSaved = false;
419
420 try {
413 - await projSource.copy(targetProjector.path);
414 - await _validateGgufFile(targetProjector);
421 + await projSource.copy(stagedProjector.path);
422 + await _validateGgufFile(stagedProjector);
423 + await stagedProjector.rename(targetProjector.path);
424
425 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 -
426 final updated = models[index].copyWith(
427 projectorPath: targetProjector.path,
428 );
429 models[index] = updated;
430 await saveAll(models);
431 + metadataSaved = true;
432 +
433 + if (oldProjPath != null && oldProjPath != targetProjector.path) {
434 + final oldProj = File(oldProjPath);
435 + if (await oldProj.exists() && p.isWithin(dir.path, oldProj.path)) {
436 + try {
437 + await oldProj.delete();
438 + } catch (_) {
439 + // Metadata already points at the validated replacement. A stale
440 + // file is safer than rolling back to a missing projector.
441 + }
442 + }
443 + }
444 return updated;
445 } catch (_) {
431 - if (await targetProjector.exists()) {
446 + if (!metadataSaved && await targetProjector.exists()) {
447 await targetProjector.delete();
448 }
449 rethrow;
450 + } finally {
451 + if (await stagedProjector.exists()) {
452 + await stagedProjector.delete();
453 + }
454 }
455 }
456
@@ -442,18 +461,23 @@ class ImportedGgufModelRepository {
461 throw StateError('Imported model no longer exists');
462 }
463
445 - final dir = await _modelsDirectory();
464 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 -
465 final updated = models[index].copyWith(projectorPath: null);
466 models[index] = updated;
467 await saveAll(models);
468 +
469 + if (oldProjPath != null) {
470 + try {
471 + final dir = await _modelsDirectory();
472 + final oldProj = File(oldProjPath);
473 + if (await oldProj.exists() && p.isWithin(dir.path, oldProj.path)) {
474 + await oldProj.delete();
475 + }
476 + } catch (_) {
477 + // The model is already safely detached in metadata. A stale file can
478 + // be cleaned up later without breaking the model entry.
479 + }
480 + }
481 return updated;
482 }
483
@@ -479,6 +503,7 @@ class ImportedGgufModelRepository {
503 }
504
505 File? targetProjectorFile;
506 + var metadataSaved = false;
507
508 try {
509 await _dio.download(
@@ -509,8 +534,9 @@ class ImportedGgufModelRepository {
534 await tempFile.rename(targetPath);
535
536 if (projectorUrl != null && projectorUrl.trim().isNotEmpty) {
512 - final normalizedProjUrl =
513 - _normalizeHuggingFaceGgufUrl(projectorUrl.trim());
537 + final normalizedProjUrl = _normalizeHuggingFaceGgufUrl(
538 + projectorUrl.trim(),
539 + );
540 final projOriginalName = _fileNameFromUri(normalizedProjUrl);
541 final projFileName =
542 '$id-mmproj-${_sanitizeFileName(projOriginalName)}';
@@ -570,6 +596,7 @@ class ImportedGgufModelRepository {
596
597 final current = load();
598 await saveAll([...current, metadata]);
599 + metadataSaved = true;
600 return metadata;
601 } on DioException catch (e) {
602 if (CancelToken.isCancel(e)) {
@@ -580,6 +607,14 @@ class ImportedGgufModelRepository {
607 if (await tempFile.exists()) {
608 await tempFile.delete();
609 }
610 + if (!metadataSaved) {
611 + if (await targetFile.exists()) {
612 + await targetFile.delete();
613 + }
614 + if (targetProjectorFile != null && await targetProjectorFile.exists()) {
615 + await targetProjectorFile.delete();
616 + }
617 + }
618 }
619 }
620
@@ -615,6 +650,14 @@ class ImportedGgufModelRepository {
650 }
651
652 Future<Directory> _modelsDirectory() async {
653 + final provided = modelsDirectoryProvider;
654 + if (provided != null) {
655 + final dir = await provided();
656 + if (!await dir.exists()) {
657 + await dir.create(recursive: true);
658 + }
659 + return dir;
660 + }
661 final supportDir = await getApplicationSupportDirectory();
662 final dir = Directory(p.join(supportDir.path, _storageDirName));
663 if (!await dir.exists()) {
@@ -623,6 +666,40 @@ class ImportedGgufModelRepository {
666 return dir;
667 }
668
669 + String? _uniqueProjectorForModel(
670 + String modelPath,
671 + List<String> projectorPaths, {
672 + required bool allowSingleFallback,
673 + }) {
674 + if (projectorPaths.isEmpty) return null;
675 + final matches = projectorPaths
676 + .where((path) => _pathsShareProjectorTokens(modelPath, path))
677 + .toList(growable: false);
678 + if (matches.length == 1) return matches.single;
679 + if (matches.isEmpty && allowSingleFallback && projectorPaths.length == 1) {
680 + return projectorPaths.single;
681 + }
682 + return null;
683 + }
684 +
685 + bool _pathsShareProjectorTokens(String modelPath, String projectorPath) {
686 + final modelBase = p.basenameWithoutExtension(modelPath).toLowerCase();
687 + final projectorBase = p
688 + .basenameWithoutExtension(projectorPath)
689 + .toLowerCase();
690 + final sharedTokens = modelBase
691 + .split(RegExp(r'[-_.]'))
692 + .where((token) => token.length > 2)
693 + .where(
694 + (token) =>
695 + token != 'model' &&
696 + token != 'instruct' &&
697 + token != 'chat' &&
698 + token != 'gguf',
699 + );
700 + return sharedTokens.any(projectorBase.contains);
701 + }
702 +
703 Uri _normalizeHuggingFaceGgufUrl(String input) {
704 final trimmed = input.trim();
705 if (trimmed.isEmpty) {
lib/features/on_device/data/on_device_chat_service.dart
+53 -32
@@ -7,6 +7,7 @@ import 'package:flutter_gemma/flutter_gemma.dart' as gemma;
7 import '../../../core/logger/app_logger.dart';
8 import '../../../core/models/enums.dart';
9 import '../../../core/utils/bpe_decoder.dart';
10 +import '../../chat/data/chat_api_error.dart';
11 import '../../chat/data/chat_service.dart';
12 import '../../chat/data/models/chat_parameters.dart';
13 import '../../chat/data/models/mcp_integration.dart';
@@ -106,20 +107,26 @@ class OnDeviceChatService implements ChatService {
107 await _closeSession(run, stopGeneration: false);
108 return;
109 }
110 + final hasCurrentImageAttachment =
111 + input?.currentMessage.attachmentPaths.any(
112 + AttachmentHelpers.isImagePath,
113 + ) ??
114 + false;
115 + if (hasCurrentImageAttachment &&
116 + !_gemmaService.currentModelSupportsVision) {
117 + await _finishWithError(
118 + run,
119 + const ChatApiError(
120 + message:
121 + 'The active model does not support image attachments. '
122 + 'Please select a vision-supported model like Gemma 4 or FastVLM.',
123 + code: ChatApiError.onDeviceVisionNotSupportedCode,
124 + ).encode(),
125 + );
126 + return;
127 + }
128 if (input == null ||
129 (input.currentMessage.content.trim().isEmpty && !input.hasImages)) {
111 - final lastMsg = messages.isNotEmpty ? messages.last : null;
112 - final hasImageAttachment =
113 - lastMsg?.attachmentPaths?.any(AttachmentHelpers.isImagePath) ??
114 - false;
115 - if (hasImageAttachment && !_gemmaService.currentModelSupportsVision) {
116 - await _finishWithError(
117 - run,
118 - 'The active model does not support image attachments. '
119 - 'Please select a vision-supported model like Gemma 4 or FastVLM.',
120 - );
121 - return;
122 - }
130 await _finishWithError(run, 'On-device chat requires a user message.');
131 return;
132 }
@@ -133,6 +140,7 @@ class OnDeviceChatService implements ChatService {
140 _retainedConversation = null;
141 _cancelRetainedExpiration();
142 session = retained.session;
143 + run.sessionSupportsImages = retained.supportsImages;
144 reusedSession = true;
145 } else {
146 if (retained != null && !input.isAuxiliary) {
@@ -144,8 +152,9 @@ class OnDeviceChatService implements ChatService {
152 session = await _gemmaService.createChat(
153 systemInstruction: input.baseSystemInstruction,
154 tools: const [],
147 - supportImage: modelSupportsVision && input.hasImages,
155 + supportImage: modelSupportsVision,
156 );
157 + run.sessionSupportsImages = modelSupportsVision;
158 reusedSession = false;
159 }
160 run.session = session;
@@ -393,8 +402,7 @@ class OnDeviceChatService implements ChatService {
402 modelId: input.modelId,
403 baseSystemInstruction: input.baseSystemInstruction,
404 timeline: input.completedTimeline(run.generatedContent.toString()),
396 - supportsImages:
397 - input.hasImages || _gemmaService.currentModelSupportsVision,
405 + supportsImages: run.sessionSupportsImages,
406 );
407 _armRetainedExpiration();
408 if (previous != null && !identical(previous.session, session)) {
@@ -487,10 +495,12 @@ class OnDeviceChatService implements ChatService {
495
496 final modelSupportsVision = _gemmaService.currentModelSupportsVision;
497 final timeline = <_MessageSnapshot>[];
490 - for (final message in timelineMessages) {
498 + for (var index = 0; index < timelineMessages.length; index++) {
499 + final message = timelineMessages[index];
500 final snapshot = await _MessageSnapshot.fromMessage(
501 message,
502 supportsVision: modelSupportsVision,
503 + loadImages: index == timelineMessages.length - 1,
504 imageCompressionEnabled: imageCompressionEnabled,
505 imageCompressionLevel: imageCompressionLevel,
506 );
@@ -563,6 +573,7 @@ class _InferenceRun {
573 _PreparedInput? input;
574 final StringBuffer generatedContent = StringBuffer();
575 bool generationStarted = false;
576 + bool sessionSupportsImages = false;
577 bool isCancelled = false;
578 bool _isFinishing = false;
579
@@ -592,9 +603,7 @@ class _PreparedInput {
603 final String? pendingAssistantId;
604 final bool isAuxiliary;
605
595 - bool get hasImages =>
596 - currentMessage.images.isNotEmpty ||
597 - history.any((message) => message.images.isNotEmpty);
606 + bool get hasImages => currentMessage.images.isNotEmpty;
607
608 gemma.Message toGemmaMessage({required bool includeHistory}) {
609 if (!includeHistory || history.isEmpty) {
@@ -614,22 +623,14 @@ class _PreparedInput {
623 'Earlier conversation transcript (context only):\n\n$transcript\n\n'
624 '$currentLabel:\n${currentMessage.content}';
625
617 - final images = currentMessage.images.isNotEmpty
618 - ? currentMessage.images
619 - : history.reversed
620 - .expand((message) => message.images)
621 - .toList(growable: false)
622 - .reversed
623 - .toList(growable: false);
624 -
625 - if (images.isNotEmpty) {
626 + if (currentMessage.images.isNotEmpty) {
627 var text = fullText;
628 if (text.trim().isEmpty) {
629 text = 'Describe the image.';
630 }
631 return gemma.Message.withImages(
632 text: text,
632 - imageBytes: images,
633 + imageBytes: currentMessage.images,
634 isUser: currentMessage.role == MessageRole.user,
635 );
636 }
@@ -708,20 +709,24 @@ class _MessageSnapshot {
709 required this.content,
710 this.images = const [],
711 this.attachmentPaths = const [],
712 + this.attachmentSignatures = const [],
713 });
714
715 static Future<_MessageSnapshot> fromMessage(
716 Message message, {
717 bool supportsVision = false,
718 + bool loadImages = true,
719 bool imageCompressionEnabled = true,
720 ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
721 }) async {
722 var content = message.content;
723 final images = <Uint8List>[];
724 + final attachmentSignatures = <String>[];
725
726 final paths = message.attachmentPaths ?? const <String>[];
727
728 for (final path in paths) {
729 + attachmentSignatures.add(await _attachmentSignature(path));
730 if (AttachmentHelpers.isDocumentPath(path)) {
731 final text = await AttachmentHelpers.readDocumentFile(path);
732 if (text != null && text.trim().isNotEmpty) {
@@ -731,7 +736,9 @@ class _MessageSnapshot {
736 text,
737 );
738 }
734 - } else if (supportsVision && AttachmentHelpers.isImagePath(path)) {
739 + } else if (loadImages &&
740 + supportsVision &&
741 + AttachmentHelpers.isImagePath(path)) {
742 final file = File(path);
743 try {
744 if (await file.exists()) {
@@ -760,15 +767,27 @@ class _MessageSnapshot {
767 content: content,
768 images: List.unmodifiable(images),
769 attachmentPaths: List.unmodifiable(paths),
770 + attachmentSignatures: List.unmodifiable(attachmentSignatures),
771 );
772 }
773
774 + static Future<String> _attachmentSignature(String path) async {
775 + try {
776 + final stat = await File(path).stat();
777 + return '$path|${stat.type}|${stat.size}|'
778 + '${stat.modified.microsecondsSinceEpoch}';
779 + } catch (_) {
780 + return '$path|unavailable';
781 + }
782 + }
783 +
784 final String id;
785 final String conversationId;
786 final MessageRole role;
787 final String content;
788 final List<Uint8List> images;
789 final List<String> attachmentPaths;
790 + final List<String> attachmentSignatures;
791
792 gemma.Message toGemmaMessage({String? promptOverride}) {
793 var text = promptOverride ?? content;
@@ -790,6 +809,7 @@ class _MessageSnapshot {
809 String? content,
810 List<Uint8List>? images,
811 List<String>? attachmentPaths,
812 + List<String>? attachmentSignatures,
813 }) => _MessageSnapshot(
814 id: id,
815 conversationId: conversationId,
@@ -797,6 +817,7 @@ class _MessageSnapshot {
817 content: content ?? this.content,
818 images: images ?? this.images,
819 attachmentPaths: attachmentPaths ?? this.attachmentPaths,
820 + attachmentSignatures: attachmentSignatures ?? this.attachmentSignatures,
821 );
822
823 @override
@@ -807,7 +828,7 @@ class _MessageSnapshot {
828 role == other.role &&
829 content == other.content &&
830 _listEquals(attachmentPaths, other.attachmentPaths) &&
810 - images.length == other.images.length;
831 + _listEquals(attachmentSignatures, other.attachmentSignatures);
832 }
833
834 @override
@@ -817,7 +838,7 @@ class _MessageSnapshot {
838 role,
839 content,
840 Object.hashAll(attachmentPaths),
820 - images.length,
841 + Object.hashAll(attachmentSignatures),
842 );
843
844 static bool _listEquals(List<String> first, List<String> second) {
lib/features/on_device/data/on_device_llama_service.dart
+41 -20
@@ -5,6 +5,7 @@ import 'package:llamadart/llamadart.dart' as llama;
5
6 import '../../../core/logger/app_logger.dart';
7 import '../../../core/models/enums.dart';
8 +import '../../chat/data/chat_api_error.dart';
9 import '../../chat/data/chat_service.dart';
10 import '../../chat/data/models/chat_parameters.dart';
11 import '../../chat/data/models/mcp_integration.dart';
@@ -25,6 +26,7 @@ class OnDeviceLlamaService {
26 llama.LlamaEngine? _engine;
27 llama.ChatSession? _session;
28 String? _currentModelId;
29 + String? _currentProjectorPath;
30 int? _currentContextLength;
31 String? _currentSystemPrompt;
32 bool _isDisposed = false;
@@ -63,8 +65,9 @@ class OnDeviceLlamaService {
65 final gpuLayers = useGpu ? llama.ModelParams.maxGpuLayers : 0;
66 final alreadyLoaded = _engine != null && _currentModelId == model.id;
67 final contextChanged = _currentContextLength != contextLength;
68 + final projectorChanged = _currentProjectorPath != model.projectorPath;
69
67 - if (alreadyLoaded && !contextChanged) {
70 + if (alreadyLoaded && !contextChanged && !projectorChanged) {
71 return;
72 }
73
@@ -76,34 +79,48 @@ class OnDeviceLlamaService {
79 );
80 final engine =
81 engineFactory?.call() ?? llama.LlamaEngine(llama.LlamaBackend());
79 - await engine.loadModel(
80 - model.localPath!,
81 - modelParams: llama.ModelParams(
82 - contextSize: contextLength,
83 - gpuLayers: gpuLayers,
84 - ),
85 - );
86 -
87 - if (model.projectorPath != null && model.projectorPath!.isNotEmpty) {
88 - final projFile = File(model.projectorPath!);
89 - if (await projFile.exists()) {
82 + try {
83 + await engine.loadModel(
84 + model.localPath!,
85 + modelParams: llama.ModelParams(
86 + contextSize: contextLength,
87 + gpuLayers: gpuLayers,
88 + ),
89 + );
90 +
91 + if (model.projectorPath != null && model.projectorPath!.isNotEmpty) {
92 + final projFile = File(model.projectorPath!);
93 + if (!await projFile.exists()) {
94 + throw FileSystemException(
95 + 'Configured multimodal projector file was not found',
96 + model.projectorPath,
97 + );
98 + }
99 Log.info('Loading multimodal projector from ${model.projectorPath}');
100 try {
101 await engine.loadMultimodalProjector(model.projectorPath!);
102 final hasVision = await engine.supportsVision;
103 + if (!hasVision) {
104 + throw StateError(
105 + 'The selected projector did not enable vision support.',
106 + );
107 + }
108 Log.info('Multimodal projector loaded (supportsVision=$hasVision)');
109 } catch (e) {
110 Log.error('Failed to load multimodal projector: $e');
111 + throw StateError('Failed to load multimodal projector: $e');
112 }
98 - } else {
99 - Log.warning(
100 - 'Configured projector file not found at ${model.projectorPath}',
101 - );
113 }
114 + } catch (_) {
115 + try {
116 + await engine.dispose();
117 + } catch (_) {}
118 + rethrow;
119 }
120
121 _engine = engine;
122 _currentModelId = model.id;
123 + _currentProjectorPath = model.projectorPath;
124 _currentContextLength = contextLength;
125 _session = null;
126 _currentSystemPrompt = null;
@@ -222,11 +239,14 @@ class OnDeviceLlamaService {
239 if (imagePaths.isNotEmpty) {
240 final supportsVision = await engine.supportsVision;
241 if (!supportsVision) {
225 - yield const ChatResponse(
242 + yield ChatResponse(
243 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.',
244 + content: ChatApiError(
245 + message:
246 + 'The active model does not support image attachments. '
247 + 'Please attach a vision projector (mmproj) to this model or select a vision-supported model.',
248 + code: ChatApiError.onDeviceVisionNotSupportedCode,
249 + ).encode(),
250 );
251 yield const ChatResponse(type: ChatResponseType.done);
252 return;
@@ -326,6 +346,7 @@ class OnDeviceLlamaService {
346 _engine = null;
347 _session = null;
348 _currentModelId = null;
349 + _currentProjectorPath = null;
350 _currentContextLength = null;
351 _currentSystemPrompt = null;
352 _lastUserMessageId = null;
lib/features/on_device/providers/on_device_providers.dart
+17
@@ -259,6 +259,9 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
259 );
260 if (ref.mounted) {
261 state = _repository.load().map((m) => m.toOnDeviceModel()).toList();
262 + await ref
263 + .read(onDeviceEngineProvider.notifier)
264 + .reloadModelIfLoaded(modelId);
265 }
266 return metadata.toOnDeviceModel();
267 }
@@ -267,6 +270,9 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
270 final metadata = await _repository.removeProjector(modelId);
271 if (ref.mounted) {
272 state = _repository.load().map((m) => m.toOnDeviceModel()).toList();
273 + await ref
274 + .read(onDeviceEngineProvider.notifier)
275 + .reloadModelIfLoaded(modelId);
276 }
277 return metadata.toOnDeviceModel();
278 }
@@ -379,6 +385,17 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
385 ref.read(onDeviceLlamaServiceProvider);
386 OnDeviceMlxService get _mlxService => ref.read(onDeviceMlxServiceProvider);
387
388 + Future<void> reloadModelIfLoaded(String modelId) async {
389 + if (!ref.mounted) return;
390 + final current = state;
391 + if (current.status != OnDeviceEngineStatus.loaded ||
392 + current.loadedModelId != modelId ||
393 + current.loadedRuntime != OnDeviceModelRuntime.llamaCpp) {
394 + return;
395 + }
396 + await loadModel(modelId, current.backend ?? PreferredBackend.cpu);
397 + }
398 +
399 Future<void> loadModel(String modelId, PreferredBackend backend) async {
400 if (!ref.mounted) return;
401 final models = ref.read(onDeviceModelsProvider);
test/features/chat/chat_error_display_test.dart new
+34
@@ -0,0 +1,34 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/features/chat/data/chat_api_error.dart';
4 +import 'package:localmind/features/chat/views/components/chat_bubble/chat_error_display.dart';
5 +import 'package:localmind/l10n/app_localizations.dart';
6 +
7 +void main() {
8 + testWidgets('localizes the on-device vision capability error', (
9 + tester,
10 + ) async {
11 + final encoded = const ChatApiError(
12 + message: 'Fallback vision error',
13 + code: ChatApiError.onDeviceVisionNotSupportedCode,
14 + ).encode();
15 +
16 + await tester.pumpWidget(
17 + MaterialApp(
18 + localizationsDelegates: AppLocalizations.localizationsDelegates,
19 + supportedLocales: AppLocalizations.supportedLocales,
20 + home: Scaffold(body: ChatErrorDisplay(errorMessage: encoded)),
21 + ),
22 + );
23 + await tester.pump();
24 +
25 + expect(
26 + find.text(
27 + 'The active model does not support image attachments. '
28 + 'Please attach a vision projector (mmproj) or select a vision-supported model.',
29 + ),
30 + findsOneWidget,
31 + );
32 + expect(find.text('Fallback vision error'), findsNothing);
33 + });
34 +}
test/features/on_device/imported_gguf_model_repository_test.dart
+185 -29
@@ -1,5 +1,6 @@
1 import 'dart:convert';
2 import 'dart:io';
3 +import 'dart:typed_data';
4
5 import 'package:dio/dio.dart';
6 import 'package:flutter_test/flutter_test.dart';
@@ -8,14 +9,24 @@ import 'package:localmind/features/on_device/data/models/on_device_model.dart';
9 import 'package:shared_preferences/shared_preferences.dart';
10
11 void main() {
12 + TestWidgetsFlutterBinding.ensureInitialized();
13 +
14 const storageKey = 'imported_gguf_models_v1';
15
16 Future<ImportedGgufModelRepository> createRepository({
17 Map<String, Object> initialValues = const {},
18 + Directory? modelsDirectory,
19 + Dio? dio,
20 }) async {
21 SharedPreferences.setMockInitialValues(initialValues);
22 final prefs = await SharedPreferences.getInstance();
18 - return ImportedGgufModelRepository(prefs, Dio());
23 + return ImportedGgufModelRepository(
24 + prefs,
25 + dio ?? Dio(),
26 + modelsDirectoryProvider: modelsDirectory == null
27 + ? null
28 + : () async => modelsDirectory,
29 + );
30 }
31
32 test(
@@ -134,7 +145,8 @@ void main() {
145 test('delete removes metadata and the copied model file', () async {
146 final file = File('${tempDir.path}/delete-me.gguf');
147 await file.writeAsString('gguf bytes');
137 - final repository = await createRepository();
148 + final storageDir = Directory('${tempDir.path}/storage');
149 + final repository = await createRepository(modelsDirectory: storageDir);
150 await repository.saveAll([
151 _metadata(id: 'gguf-delete-me', filePath: file.path),
152 ]);
@@ -192,34 +204,64 @@ void main() {
204 throwsA(isA<FormatException>()),
205 );
206 });
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();
207
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 - });
208 + test(
209 + 'failed projector download removes the finalized Hugging Face model',
210 + () async {
211 + final storageDir = Directory('${tempDir.path}/storage');
212 + final dio = Dio()..httpClientAdapter = _GgufDownloadAdapter();
213 + final repository = await createRepository(
214 + modelsDirectory: storageDir,
215 + dio: dio,
216 + );
217 +
218 + await expectLater(
219 + repository.importFromHuggingFaceUrl(
220 + 'https://huggingface.co/org/repo/resolve/main/model.gguf',
221 + projectorUrl:
222 + 'https://huggingface.co/org/repo/resolve/main/bad-mmproj.gguf',
223 + ),
224 + throwsA(isA<FormatException>()),
225 + );
226 +
227 + expect(repository.load(), isEmpty);
228 + expect(storageDir.listSync(), isEmpty);
229 + },
230 + );
231 +
232 + test(
233 + 'serializes and converts to a llama.cpp on-device model with projector',
234 + () {
235 + final importedAt = DateTime.utc(2026, 6, 21, 12);
236 + final metadata = ImportedGgufModelMetadata(
237 + id: 'gguf-vlm',
238 + name: 'LLaVA 1.6',
239 + filePath: '/tmp/llava.gguf',
240 + projectorPath: '/tmp/mmproj-llava.gguf',
241 + fileSizeBytes: 1234,
242 + importedAt: importedAt,
243 + source: OnDeviceImportedSource.localFile,
244 + );
245 +
246 + final restored = ImportedGgufModelMetadata.fromJson(
247 + json.decode(json.encode(metadata.toJson())) as Map<String, dynamic>,
248 + );
249 + final model = restored.toOnDeviceModel();
250 +
251 + expect(restored.projectorPath, '/tmp/mmproj-llava.gguf');
252 + expect(restored.projectorFileName, 'mmproj-llava.gguf');
253 + expect(model.supportsVision, isTrue);
254 + expect(model.hasProjector, isTrue);
255 + expect(model.projectorPath, '/tmp/mmproj-llava.gguf');
256 + expect(model.projectorFileName, 'mmproj-llava.gguf');
257 + },
258 + );
259
260 test('isProjectorFileName correctly identifies vision projector files', () {
261 expect(
222 - ImportedGgufModelRepository.isProjectorFileName('mmproj-model-f16.gguf'),
262 + ImportedGgufModelRepository.isProjectorFileName(
263 + 'mmproj-model-f16.gguf',
264 + ),
265 isTrue,
266 );
267 expect(
@@ -227,11 +269,15 @@ void main() {
269 isTrue,
270 );
271 expect(
230 - ImportedGgufModelRepository.isProjectorFileName('vision_projector.gguf'),
272 + ImportedGgufModelRepository.isProjectorFileName(
273 + 'vision_projector.gguf',
274 + ),
275 isTrue,
276 );
277 expect(
234 - ImportedGgufModelRepository.isProjectorFileName('qwen2.5-7b-instruct.gguf'),
278 + ImportedGgufModelRepository.isProjectorFileName(
279 + 'qwen2.5-7b-instruct.gguf',
280 + ),
281 isFalse,
282 );
283 });
@@ -266,7 +312,9 @@ void main() {
312 await modelFile.writeAsBytes(ggufBytes);
313 await projFile.writeAsBytes(ggufBytes);
314
269 - final repository = await createRepository();
315 + final repository = await createRepository(
316 + modelsDirectory: Directory('${tempDir.path}/storage'),
317 + );
318 await repository.saveAll([
319 _metadata(id: 'gguf-attach-test', filePath: modelFile.path),
320 ]);
@@ -282,8 +330,92 @@ void main() {
330 final removed = await repository.removeProjector('gguf-attach-test');
331 expect(removed.projectorPath, isNull);
332 expect(removed.toOnDeviceModel().supportsVision, isFalse);
333 + expect(await File(attached.projectorPath!).exists(), isFalse);
334 });
335
336 + test('replacing a projector removes the previous copied file', () async {
337 + final firstProjector = File('${tempDir.path}/mmproj-first.gguf');
338 + final secondProjector = File('${tempDir.path}/mmproj-second.gguf');
339 + const ggufBytes = [0x47, 0x47, 0x55, 0x46, 0x00, 0x00];
340 + await firstProjector.writeAsBytes(ggufBytes);
341 + await secondProjector.writeAsBytes(ggufBytes);
342 +
343 + final repository = await createRepository(
344 + modelsDirectory: Directory('${tempDir.path}/storage'),
345 + );
346 + await repository.saveAll([
347 + _metadata(
348 + id: 'gguf-replace-test',
349 + filePath: '${tempDir.path}/model.gguf',
350 + ),
351 + ]);
352 +
353 + final first = await repository.attachProjector(
354 + 'gguf-replace-test',
355 + firstProjector.path,
356 + );
357 + final firstCopiedPath = first.projectorPath!;
358 + expect(await File(firstCopiedPath).exists(), isTrue);
359 +
360 + final second = await repository.attachProjector(
361 + 'gguf-replace-test',
362 + secondProjector.path,
363 + );
364 + expect(second.projectorPath, isNot(firstCopiedPath));
365 + expect(await File(firstCopiedPath).exists(), isFalse);
366 + expect(await File(second.projectorPath!).exists(), isTrue);
367 + });
368 +
369 + test(
370 + 'single-file import does not guess between ambiguous projectors',
371 + () async {
372 + final sourceDir = Directory('${tempDir.path}/source')
373 + ..createSync(recursive: true);
374 + const ggufBytes = [0x47, 0x47, 0x55, 0x46, 0x00, 0x00];
375 + final model = File('${sourceDir.path}/model.gguf');
376 + await model.writeAsBytes(ggufBytes);
377 + await File(
378 + '${sourceDir.path}/mmproj-alpha.gguf',
379 + ).writeAsBytes(ggufBytes);
380 + await File(
381 + '${sourceDir.path}/mmproj-beta.gguf',
382 + ).writeAsBytes(ggufBytes);
383 + final repository = await createRepository(
384 + modelsDirectory: Directory('${tempDir.path}/storage'),
385 + );
386 +
387 + final imported = await repository.importFromPath(model.path);
388 +
389 + expect(imported.projectorPath, isNull);
390 + },
391 + );
392 +
393 + test(
394 + 'multi-file import rejects projectors that cannot be matched uniquely',
395 + () async {
396 + const ggufBytes = [0x47, 0x47, 0x55, 0x46, 0x00, 0x00];
397 + final model = File('${tempDir.path}/model.gguf');
398 + final firstProjector = File('${tempDir.path}/mmproj-alpha.gguf');
399 + final secondProjector = File('${tempDir.path}/mmproj-beta.gguf');
400 + await model.writeAsBytes(ggufBytes);
401 + await firstProjector.writeAsBytes(ggufBytes);
402 + await secondProjector.writeAsBytes(ggufBytes);
403 + final repository = await createRepository(
404 + modelsDirectory: Directory('${tempDir.path}/storage'),
405 + );
406 +
407 + await expectLater(
408 + repository.importFromPaths([
409 + model.path,
410 + firstProjector.path,
411 + secondProjector.path,
412 + ]),
413 + throwsA(isA<FormatException>()),
414 + );
415 + expect(repository.load(), isEmpty);
416 + },
417 + );
418 +
419 test('rejects standalone mmproj file import without model', () async {
420 final projFile = File('${tempDir.path}/mmproj-only.gguf');
421 await projFile.writeAsBytes([0x47, 0x47, 0x55, 0x46]);
@@ -297,6 +429,30 @@ void main() {
429 });
430 }
431
432 +class _GgufDownloadAdapter implements HttpClientAdapter {
433 + @override
434 + Future<ResponseBody> fetch(
435 + RequestOptions options,
436 + Stream<Uint8List>? requestStream,
437 + Future<void>? cancelFuture,
438 + ) async {
439 + final isProjector = options.uri.path.endsWith('bad-mmproj.gguf');
440 + final bytes = isProjector
441 + ? const [0x00, 0x01, 0x02, 0x03]
442 + : const [0x47, 0x47, 0x55, 0x46, 0x00, 0x00];
443 + return ResponseBody.fromBytes(
444 + bytes,
445 + HttpStatus.ok,
446 + headers: {
447 + Headers.contentLengthHeader: [bytes.length.toString()],
448 + },
449 + );
450 + }
451 +
452 + @override
453 + void close({bool force = false}) {}
454 +}
455 +
456 ImportedGgufModelMetadata _metadata({
457 required String id,
458 required String filePath,
test/features/on_device/on_device_chat_attachments_test.dart
+166 -10
@@ -154,14 +154,14 @@ void main() {
154 );
155
156 test(
157 - 'silently drops image attachment when model is text-only and text is present',
157 + 'returns an error when text and an image target a text-only model',
158 () async {
159 inferenceService.currentModelSupportsVision = false;
160 final imgFile = File('${tempDir.path}/screenshot.png');
161 await imgFile.writeAsBytes(Uint8List.fromList([1, 2, 3]));
162
163 final responses = <ChatResponse>[];
164 - final done = chatService
164 + await chatService
165 .sendMessage(
166 server: _server(),
167 modelId: 'qwen3-0.6b',
@@ -173,17 +173,54 @@ void main() {
173 .listen(responses.add)
174 .asFuture<void>();
175
176 - await _waitFor(() => inferenceService.sessions.isNotEmpty);
177 - expect(inferenceService.supportImages.single, isFalse);
176 + final error = responses.firstWhere(
177 + (response) => response.type == ChatResponseType.error,
178 + );
179 + expect(error.content, contains('does not support image attachments'));
180 + expect(inferenceService.sessions, isEmpty);
181 + },
182 + );
183
179 - final session = inferenceService.sessions.single;
180 - await _waitFor(() => session.messages.isNotEmpty);
184 + test(
185 + 'does not attach historical images to a later text-only turn',
186 + () async {
187 + inferenceService.currentModelSupportsVision = true;
188 + final imgFile = File('${tempDir.path}/historical.png');
189 + await imgFile.writeAsBytes(Uint8List.fromList([4, 5, 6]));
190
182 - final message = session.messages.single;
183 - expect(message.hasImage, isFalse);
184 - expect(message.text, equals('Explain this code'));
191 + final done = chatService
192 + .sendMessage(
193 + server: _server(),
194 + modelId: 'gemma4-e2b-instruct',
195 + messages: [
196 + _message(
197 + 'What is shown?',
198 + id: 'user-1',
199 + attachmentPaths: [imgFile.path],
200 + ),
201 + _message(
202 + 'A diagram.',
203 + id: 'assistant-1',
204 + role: MessageRole.assistant,
205 + ),
206 + _message('Explain the answer', id: 'user-2'),
207 + ],
208 + params: ChatParameters.defaults(),
209 + )
210 + .listen((_) {})
211 + .asFuture<void>();
212
186 - session.responses.add(const gemma.TextResponse('Code explanation.'));
213 + await _waitFor(
214 + () =>
215 + inferenceService.sessions.isNotEmpty &&
216 + inferenceService.sessions.single.messages.isNotEmpty,
217 + );
218 + final session = inferenceService.sessions.single;
219 + expect(session.messages.single.hasImage, isFalse);
220 + expect(session.messages.single.text, contains('User: What is shown?'));
221 + expect(session.messages.single.text, contains('Explain the answer'));
222 +
223 + session.responses.add(const gemma.TextResponse('Explanation'));
224 await session.responses.close();
225 await done;
226 },
@@ -279,6 +316,125 @@ void main() {
316 await secondDone;
317 },
318 );
319 +
320 + test(
321 + 'reuses a vision-capable session when a later turn adds an image',
322 + () async {
323 + inferenceService.currentModelSupportsVision = true;
324 + final firstDone = chatService
325 + .sendMessage(
326 + server: _server(),
327 + modelId: 'gemma4-e2b-instruct',
328 + messages: [
329 + _message('Hello', id: 'user-1'),
330 + _message('', id: 'assistant-1', role: MessageRole.assistant),
331 + ],
332 + params: ChatParameters.defaults(),
333 + )
334 + .listen((_) {})
335 + .asFuture<void>();
336 +
337 + await _waitFor(() => inferenceService.sessions.isNotEmpty);
338 + final session = inferenceService.sessions.single;
339 + expect(inferenceService.supportImages.single, isTrue);
340 + session.responses.add(const gemma.TextResponse('Hi there!'));
341 + await session.responses.close();
342 + await firstDone;
343 +
344 + final imgFile = File('${tempDir.path}/follow-up.png');
345 + final bytes = Uint8List.fromList([7, 8, 9]);
346 + await imgFile.writeAsBytes(bytes);
347 + final secondDone = chatService
348 + .sendMessage(
349 + server: _server(),
350 + modelId: 'gemma4-e2b-instruct',
351 + messages: [
352 + _message('Hello', id: 'user-1'),
353 + _message(
354 + 'Hi there!',
355 + id: 'assistant-1',
356 + role: MessageRole.assistant,
357 + ),
358 + _message(
359 + 'Now inspect this',
360 + id: 'user-2',
361 + attachmentPaths: [imgFile.path],
362 + ),
363 + ],
364 + params: ChatParameters.defaults(),
365 + )
366 + .listen((_) {})
367 + .asFuture<void>();
368 +
369 + await _waitFor(() => session.messages.length == 2);
370 + expect(inferenceService.createCount, 1);
371 + expect(session.messages.last.hasImage, isTrue);
372 + expect(session.messages.last.imageBytes, equals(bytes));
373 + session.responses.add(const gemma.TextResponse('Seen.'));
374 + await session.responses.close();
375 + await secondDone;
376 + },
377 + );
378 +
379 + test(
380 + 'rebuilds a retained session when an attachment changes in place',
381 + () async {
382 + inferenceService.currentModelSupportsVision = true;
383 + final imgFile = File('${tempDir.path}/mutable.png');
384 + await imgFile.writeAsBytes(Uint8List.fromList([1, 2, 3]));
385 +
386 + final firstDone = chatService
387 + .sendMessage(
388 + server: _server(),
389 + modelId: 'gemma4-e2b-instruct',
390 + messages: [
391 + _message(
392 + 'Inspect this',
393 + id: 'user-1',
394 + attachmentPaths: [imgFile.path],
395 + ),
396 + ],
397 + params: ChatParameters.defaults(),
398 + )
399 + .listen((_) {})
400 + .asFuture<void>();
401 + await _waitFor(() => inferenceService.sessions.isNotEmpty);
402 + final firstSession = inferenceService.sessions.single;
403 + firstSession.responses.add(const gemma.TextResponse('Original'));
404 + await firstSession.responses.close();
405 + await firstDone;
406 +
407 + await imgFile.writeAsBytes(Uint8List.fromList([9, 8, 7, 6, 5]));
408 + final secondDone = chatService
409 + .sendMessage(
410 + server: _server(),
411 + modelId: 'gemma4-e2b-instruct',
412 + messages: [
413 + _message(
414 + 'Inspect this',
415 + id: 'user-1',
416 + attachmentPaths: [imgFile.path],
417 + ),
418 + _message(
419 + 'Original',
420 + id: 'assistant-1',
421 + role: MessageRole.assistant,
422 + ),
423 + _message('Continue', id: 'user-2'),
424 + ],
425 + params: ChatParameters.defaults(),
426 + )
427 + .listen((_) {})
428 + .asFuture<void>();
429 +
430 + await _waitFor(() => inferenceService.sessions.length == 2);
431 + expect(firstSession.closeCount, 1);
432 + final secondSession = inferenceService.sessions.last;
433 + secondSession.responses.add(const gemma.TextResponse('Updated'));
434 + await secondSession.responses.close();
435 + await secondDone;
436 + },
437 + );
438 });
439
440 group('OnDeviceGemmaService vision support detection', () {
test/features/on_device/on_device_llama_reasoning_test.dart
+50 -10
@@ -1,3 +1,5 @@
1 +import 'dart:io';
2 +
3 import 'package:flutter_test/flutter_test.dart';
4 import 'package:llamadart/llamadart.dart' as llama;
5 import 'package:localmind/core/models/enums.dart';
@@ -111,15 +113,20 @@ void main() {
113 );
114
115 test('loads multimodal projector when model has projectorPath', () async {
114 - final engine = RecordingEngine();
115 - final service = OnDeviceLlamaService(
116 - engineFactory: () => engine,
116 + final tempDir = await Directory.systemTemp.createTemp(
117 + 'localmind_llama_projector_',
118 );
119 + addTearDown(() => tempDir.delete(recursive: true));
120 + final projector = await File(
121 + '${tempDir.path}/model-mmproj.gguf',
122 + ).writeAsBytes([0x47, 0x47, 0x55, 0x46]);
123 + final engine = RecordingEngine()..visionSupported = true;
124 + final service = OnDeviceLlamaService(engineFactory: () => engine);
125 final model = ImportedGgufModelMetadata(
126 id: 'vision-model',
127 name: 'Vision Model',
128 filePath: '/tmp/model.gguf',
122 - projectorPath: '/tmp/model-mmproj.gguf',
129 + projectorPath: projector.path,
130 fileSizeBytes: 4,
131 importedAt: DateTime.utc(2026),
132 source: OnDeviceImportedSource.localFile,
@@ -127,19 +134,51 @@ void main() {
134
135 await service.loadModel(model);
136
130 - expect(engine.loadedProjectors, ['/tmp/model-mmproj.gguf']);
137 + expect(engine.loadedProjectors, [projector.path]);
138 expect(model.supportsVision, isTrue);
139 expect(model.hasProjector, isTrue);
140 expect(model.projectorFileName, 'model-mmproj.gguf');
141 });
142
143 + test('reloads the same model when its projector changes', () async {
144 + final tempDir = await Directory.systemTemp.createTemp(
145 + 'localmind_llama_projector_reload_',
146 + );
147 + addTearDown(() => tempDir.delete(recursive: true));
148 + final projector = await File(
149 + '${tempDir.path}/replacement-mmproj.gguf',
150 + ).writeAsBytes([0x47, 0x47, 0x55, 0x46]);
151 + final engines = <RecordingEngine>[];
152 + final service = OnDeviceLlamaService(
153 + engineFactory: () {
154 + final engine = RecordingEngine()..visionSupported = true;
155 + engines.add(engine);
156 + return engine;
157 + },
158 + );
159 + final baseMetadata = ImportedGgufModelMetadata(
160 + id: 'vision-model',
161 + name: 'Vision Model',
162 + filePath: '/tmp/model.gguf',
163 + fileSizeBytes: 4,
164 + importedAt: DateTime.utc(2026),
165 + source: OnDeviceImportedSource.localFile,
166 + );
167 +
168 + await service.loadModel(baseMetadata.toOnDeviceModel());
169 + await service.loadModel(
170 + baseMetadata.copyWith(projectorPath: projector.path).toOnDeviceModel(),
171 + );
172 +
173 + expect(engines, hasLength(2));
174 + expect(engines.last.loadedProjectors, [projector.path]);
175 + });
176 +
177 test(
178 'returns error when message has image attachments but model does not support vision',
179 () async {
180 final engine = RecordingEngine()..visionSupported = false;
140 - final service = OnDeviceLlamaService(
141 - engineFactory: () => engine,
142 - );
181 + final service = OnDeviceLlamaService(engineFactory: () => engine);
182 final model = ImportedGgufModelMetadata(
183 id: 'text-model',
184 name: 'Text Model',
@@ -170,8 +209,9 @@ void main() {
209 )
210 .toList();
211
173 - final errors =
174 - responses.where((r) => r.type == ChatResponseType.error).toList();
212 + final errors = responses
213 + .where((r) => r.type == ChatResponseType.error)
214 + .toList();
215 expect(errors, isNotEmpty);
216 expect(
217 errors.first.content,