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,