38 files changed
+2228
-269
.fvmrc
+1
-1
@@ -1,3 +1,3 @@
1
{
2
- "flutter": "3.41.7"
2
+ "flutter": "3.44.2"
3
}
\ No newline at end of file
.vscode/settings.json
+1
-1
@@ -1,3 +1,3 @@
1
{
2
- "dart.flutterSdkPath": ".fvm/versions/3.41.7"
2
+ "dart.flutterSdkPath": ".fvm/versions/3.44.2"
3
}
\ No newline at end of file
ios/Podfile
+2
-1
@@ -1,5 +1,6 @@
1
# Uncomment this line to define a global platform for your project
2
-platform :ios, '16.0'
2
+# Bumped to 16.4 by llamadart_llama_cpp_flutter (0.0.4) for the GGUF import path.
3
+platform :ios, '16.4'
4
5
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
6
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
ios/Runner.xcodeproj/project.pbxproj
+3
-3
@@ -470,7 +470,7 @@
470
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
471
GCC_WARN_UNUSED_FUNCTION = YES;
472
GCC_WARN_UNUSED_VARIABLE = YES;
473
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
473
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
474
MTL_ENABLE_DEBUG_INFO = NO;
475
SDKROOT = iphoneos;
476
SUPPORTED_PLATFORMS = iphoneos;
@@ -601,7 +601,7 @@
601
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
602
GCC_WARN_UNUSED_FUNCTION = YES;
603
GCC_WARN_UNUSED_VARIABLE = YES;
604
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
604
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
605
MTL_ENABLE_DEBUG_INFO = YES;
606
ONLY_ACTIVE_ARCH = YES;
607
SDKROOT = iphoneos;
@@ -652,7 +652,7 @@
652
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
653
GCC_WARN_UNUSED_FUNCTION = YES;
654
GCC_WARN_UNUSED_VARIABLE = YES;
655
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
655
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
656
MTL_ENABLE_DEBUG_INFO = NO;
657
SDKROOT = iphoneos;
658
SUPPORTED_PLATFORMS = iphoneos;
lib/bootstrap/bootstrap_host.dart
+6
-1
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
5
import 'package:shared_preferences/shared_preferences.dart';
6
7
import '../app.dart';
8
+import '../core/providers/app_providers.dart';
9
import '../core/providers/highlighter_provider.dart';
10
import '../core/providers/storage_providers.dart';
11
import '../core/storage/objectbox_store.dart';
@@ -66,7 +67,11 @@ class _BootstrapHostState extends State<BootstrapHost> {
67
);
68
69
_updateStage(BootstrapStage.initializingServices, 'Initializing services...');
69
- await OnDeviceGemmaService.initialize();
70
+
71
+ final hfToken = container.read(
72
+ settingsProvider.select((s) => s.huggingFaceToken),
73
+ );
74
+ await OnDeviceGemmaService.initialize(huggingFaceToken: hfToken);
75
76
// Migrate models from old custom download location in background to prevent cold start ANR
77
OnDeviceGemmaService.migrateOldModels().catchError((e) {
lib/core/providers/app_providers.dart
+2
@@ -102,6 +102,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
102
_update(state.copyWith(smartReplyEnabled: value));
103
void setLocaleCode(String? value) =>
104
_update(state.copyWith(localeCode: value));
105
+ void setHuggingFaceToken(String? value) =>
106
+ _update(state.copyWith(huggingFaceToken: value));
107
108
Future<void> _update(AppSettings updated) async {
109
state = updated;
lib/features/chat/providers/chat_providers.dart
+36
-11
@@ -2,6 +2,7 @@ import 'dart:async';
2
import 'dart:io';
3
import 'dart:math';
4
5
+import 'package:dio/dio.dart';
6
import '../../../core/logger/app_logger.dart';
7
import '../../../core/services/message_save_service.dart';
8
@@ -21,6 +22,9 @@ import '../../../core/providers/service_providers.dart';
22
import '../../../core/providers/storage_providers.dart';
23
import '../../on_device/providers/on_device_providers.dart';
24
import '../../on_device/data/on_device_chat_service.dart';
25
+import '../../on_device/data/on_device_gemma_service.dart';
26
+import '../../on_device/data/on_device_llama_service.dart';
27
+import '../../on_device/data/models/on_device_model.dart';
28
import '../../../core/storage/entities.dart';
29
import '../../../objectbox.g.dart';
30
import '../../conversations/data/models/conversation.dart';
@@ -248,17 +252,16 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
252
if (server == null) {
253
return null;
254
}
251
- final ChatService service;
252
- if (server.type == ServerType.onDevice) {
253
- final gemmaService = ref.read(onDeviceGemmaServiceProvider);
254
- service = ChatService.forServer(
255
- server.type,
256
- ref.read(dioProvider),
257
- onDeviceGemma: gemmaService,
258
- );
259
- } else {
260
- service = ChatService.forServer(server.type, ref.read(dioProvider));
261
- }
255
+ final loadedRuntime = ref.watch(
256
+ onDeviceEngineProvider.select((s) => s.loadedRuntime),
257
+ );
258
+ final service = createChatServiceForServer(
259
+ server: server,
260
+ dio: ref.read(dioProvider),
261
+ onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
262
+ onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
263
+ loadedOnDeviceRuntime: loadedRuntime,
264
+ );
265
266
ref.onDispose(() {
267
final s = service;
@@ -270,6 +273,28 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
273
return service;
274
});
275
276
+ChatService createChatServiceForServer({
277
+ required Server server,
278
+ required Dio dio,
279
+ required OnDeviceGemmaService onDeviceGemmaService,
280
+ required OnDeviceLlamaService onDeviceLlamaService,
281
+ OnDeviceModelRuntime? loadedOnDeviceRuntime,
282
+}) {
283
+ if (server.type == ServerType.onDevice) {
284
+ if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
285
+ return OnDeviceLlamaChatService(onDeviceLlamaService);
286
+ }
287
+
288
+ return ChatService.forServer(
289
+ server.type,
290
+ dio,
291
+ onDeviceGemma: onDeviceGemmaService,
292
+ );
293
+ }
294
+
295
+ return ChatService.forServer(server.type, dio);
296
+}
297
+
298
final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
299
final service = SmartReplyService();
300
ref.onDispose(() => service.dispose());
lib/features/models/data/models/model_info.dart
+13
@@ -1,4 +1,5 @@
1
import '../../../../core/models/enums.dart';
2
+import '../../../on_device/data/models/on_device_model.dart';
3
4
class ModelInfo {
5
final String id;
@@ -13,6 +14,9 @@ class ModelInfo {
14
final String serverId;
15
final DateTime? modifiedAt;
16
final ModelStatus status;
17
+ final OnDeviceModelRuntime? onDeviceRuntime;
18
+ final OnDeviceModelFormat? onDeviceFormat;
19
+ final String? localPath;
20
21
ModelInfo({
22
required this.id,
@@ -27,6 +31,9 @@ class ModelInfo {
31
required this.serverId,
32
this.modifiedAt,
33
this.status = ModelStatus.unloaded,
34
+ this.onDeviceRuntime,
35
+ this.onDeviceFormat,
36
+ this.localPath,
37
});
38
39
String get displayName {
@@ -73,6 +80,9 @@ class ModelInfo {
80
String? serverId,
81
DateTime? modifiedAt,
82
ModelStatus? status,
83
+ OnDeviceModelRuntime? onDeviceRuntime,
84
+ OnDeviceModelFormat? onDeviceFormat,
85
+ String? localPath,
86
}) {
87
return ModelInfo(
88
id: id ?? this.id,
@@ -87,6 +97,9 @@ class ModelInfo {
97
serverId: serverId ?? this.serverId,
98
modifiedAt: modifiedAt ?? this.modifiedAt,
99
status: status ?? this.status,
100
+ onDeviceRuntime: onDeviceRuntime ?? this.onDeviceRuntime,
101
+ onDeviceFormat: onDeviceFormat ?? this.onDeviceFormat,
102
+ localPath: localPath ?? this.localPath,
103
);
104
}
105
}
lib/features/models/screens/model_picker_sheet.dart
+84
-9
@@ -2,8 +2,10 @@ import 'dart:io';
2
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:go_router/go_router.dart';
6
import 'package:localmind/l10n/app_localizations.dart';
7
import 'package:localmind/core/theme/colors.dart';
8
+import '../../../core/routes/app_routes.dart';
9
import '../../chat/providers/chat_providers.dart';
10
import '../data/models/model_info.dart';
11
import '../../servers/providers/server_providers.dart';
@@ -46,11 +48,20 @@ class ModelPickerSheet extends ConsumerWidget {
48
ref.listen(onDeviceEngineProvider, (prev, next) {
49
if (next.status == OnDeviceEngineStatus.loaded &&
50
prev?.status == OnDeviceEngineStatus.loading) {
51
+ final loadedId = next.loadedModelId;
52
+ final models = ref.read(onDeviceModelsProvider);
53
+ final loadedName = loadedId == null
54
+ ? 'Unknown'
55
+ : models
56
+ .where((m) => m.id == loadedId)
57
+ .map((m) => m.name)
58
+ .followedBy([loadedId])
59
+ .first;
60
ScaffoldMessenger.of(context).showSnackBar(
61
SnackBar(
62
content: Text(
63
l10n.model_loaded(
53
- next.loadedModelId ?? 'Unknown',
64
+ loadedName,
65
next.backend?.name ?? 'CPU',
66
),
67
),
@@ -227,7 +238,9 @@ class _NoServerState extends StatelessWidget {
238
l10n.add_server_first,
239
style: TextStyle(
240
fontSize: 13,
230
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
241
+ color: isDark
242
+ ? AppColors.darkMutedText
243
+ : AppColors.lightMutedText,
244
),
245
),
246
],
@@ -360,9 +373,7 @@ class _ModelList extends ConsumerWidget {
373
final activeServer = ref.read(activeServerProvider);
374
if (activeServer == null) return;
375
363
- final supportsLoad =
364
- model.serverType == ServerType.lmStudio ||
365
- model.serverType == ServerType.ollama;
376
+ final supportsLoad = model.serverType == ServerType.lmStudio;
377
378
if (supportsLoad && !isLoaded) {
379
ref.read(modelLoadingProvider.notifier).setLoading(model.id);
@@ -540,7 +551,9 @@ class _ModelTile extends StatelessWidget {
551
width: 15,
552
height: 15,
553
decoration: BoxDecoration(
543
- color: isDark ? AppColors.darkAccent : AppColors.lightAccent,
554
+ color: isDark
555
+ ? AppColors.darkAccent
556
+ : AppColors.lightAccent,
557
shape: BoxShape.circle,
558
),
559
),
@@ -870,6 +883,26 @@ class _OnDeviceModelTile extends ConsumerWidget {
883
),
884
),
885
),
886
+ if (model.isImported)
887
+ Container(
888
+ margin: const EdgeInsets.only(left: 6),
889
+ padding: const EdgeInsets.symmetric(
890
+ horizontal: 6,
891
+ vertical: 2,
892
+ ),
893
+ decoration: BoxDecoration(
894
+ color: Colors.blue.withValues(alpha: 0.1),
895
+ borderRadius: BorderRadius.circular(4),
896
+ ),
897
+ child: const Text(
898
+ 'Imported',
899
+ style: TextStyle(
900
+ fontSize: 9,
901
+ fontWeight: FontWeight.bold,
902
+ color: Colors.blue,
903
+ ),
904
+ ),
905
+ ),
906
],
907
),
908
if (!Platform.isIOS &&
@@ -935,6 +968,10 @@ class _OnDeviceModelTile extends ConsumerWidget {
968
label: model.fileSizeFormatted,
969
isDark: isDark,
970
),
971
+ if (model.format == OnDeviceModelFormat.gguf)
972
+ _MetadataChip(label: 'GGUF', isDark: isDark),
973
+ if (model.runtime == OnDeviceModelRuntime.llamaCpp)
974
+ _MetadataChip(label: 'llama.cpp', isDark: isDark),
975
_MetadataChip(label: model.license, isDark: isDark),
976
_MetadataChip(
977
label: model.parameterLabel,
@@ -1013,10 +1050,24 @@ class _OnDeviceModelTile extends ConsumerWidget {
1050
: AppColors.lightMutedText,
1051
),
1052
tooltip: l10n.download,
1016
- onPressed: () {
1017
- ref
1053
+ onPressed: () async {
1054
+ final result = await ref
1055
.read(foregroundDownloadNotifierProvider.notifier)
1056
.startDownload(model.id);
1057
+ if (result == 'missing_huggingface_token' &&
1058
+ context.mounted) {
1059
+ ScaffoldMessenger.of(context).showSnackBar(
1060
+ SnackBar(
1061
+ content: Text(l10n.model_missing_huggingface_token),
1062
+ duration: const Duration(seconds: 6),
1063
+ action: SnackBarAction(
1064
+ label: l10n.settings_title,
1065
+ onPressed: () =>
1066
+ context.push(AppRoutes.settings),
1067
+ ),
1068
+ ),
1069
+ );
1070
+ }
1071
},
1072
),
1073
const SizedBox(width: 4),
@@ -1059,8 +1110,17 @@ class _OnDeviceModelTile extends ConsumerWidget {
1110
parameterCount: double.tryParse(
1111
model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
1112
),
1113
+ fileSize: model.fileSizeBytes,
1114
+ quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
1115
+ architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
1116
+ ? 'llama.cpp'
1117
+ : null,
1118
serverType: ServerType.onDevice,
1119
serverId: 'on-device',
1120
+ modifiedAt: model.importedAt,
1121
+ onDeviceRuntime: model.runtime,
1122
+ onDeviceFormat: model.format,
1123
+ localPath: model.localPath,
1124
);
1125
1126
await engineNotifier.loadModel(model.id, settings.preferredBackend);
@@ -1115,8 +1175,17 @@ class _OnDeviceModelTile extends ConsumerWidget {
1175
parameterCount: double.tryParse(
1176
model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
1177
),
1178
+ fileSize: model.fileSizeBytes,
1179
+ quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
1180
+ architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
1181
+ ? 'llama.cpp'
1182
+ : null,
1183
serverType: ServerType.onDevice,
1184
serverId: 'on-device',
1185
+ modifiedAt: model.importedAt,
1186
+ onDeviceRuntime: model.runtime,
1187
+ onDeviceFormat: model.format,
1188
+ localPath: model.localPath,
1189
);
1190
1191
ref.read(selectedModelProvider.notifier).setModel(modelInfo);
@@ -1168,7 +1237,13 @@ class _OnDeviceModelTile extends ConsumerWidget {
1237
selectedModelNotifier.clear();
1238
}
1239
1171
- await gemmaService.deleteModel(model.id);
1240
+ if (model.isImported) {
1241
+ await ref
1242
+ .read(importedGgufModelsProvider.notifier)
1243
+ .deleteModel(model.id);
1244
+ } else {
1245
+ await gemmaService.deleteModel(model.id);
1246
+ }
1247
if (!context.mounted) return;
1248
ref.invalidate(downloadedModelsProvider);
1249
}
lib/features/on_device/data/imported_gguf_model_repository.dart
new
+201
@@ -0,0 +1,201 @@
1
+import 'dart:convert';
2
+import 'dart:io';
3
+import 'dart:math';
4
+
5
+import 'package:path/path.dart' as p;
6
+import 'package:path_provider/path_provider.dart';
7
+import 'package:shared_preferences/shared_preferences.dart';
8
+
9
+import 'models/on_device_model.dart';
10
+
11
+class ImportedGgufModelMetadata {
12
+ final String id;
13
+ final String name;
14
+ final String filePath;
15
+ final int fileSizeBytes;
16
+ final DateTime importedAt;
17
+
18
+ const ImportedGgufModelMetadata({
19
+ required this.id,
20
+ required this.name,
21
+ required this.filePath,
22
+ required this.fileSizeBytes,
23
+ required this.importedAt,
24
+ });
25
+
26
+ String get fileName => p.basename(filePath);
27
+
28
+ OnDeviceModel toOnDeviceModel() {
29
+ return OnDeviceModel(
30
+ id: id,
31
+ name: name,
32
+ huggingFaceUrl: '',
33
+ fileSizeBytes: fileSizeBytes,
34
+ license: 'Local file',
35
+ description: 'Imported GGUF model for local llama.cpp inference.',
36
+ minRamMb: 2048,
37
+ parameterLabel: 'GGUF',
38
+ bestFor: 'Local GGUF inference',
39
+ languagesLabel: 'Local',
40
+ backendNote: 'llama.cpp',
41
+ isCpuOnly: true,
42
+ runtime: OnDeviceModelRuntime.llamaCpp,
43
+ format: OnDeviceModelFormat.gguf,
44
+ localPath: filePath,
45
+ importedAt: importedAt,
46
+ isImported: true,
47
+ );
48
+ }
49
+
50
+ Map<String, dynamic> toJson() {
51
+ return {
52
+ 'id': id,
53
+ 'name': name,
54
+ 'filePath': filePath,
55
+ 'fileSizeBytes': fileSizeBytes,
56
+ 'importedAt': importedAt.toIso8601String(),
57
+ };
58
+ }
59
+
60
+ factory ImportedGgufModelMetadata.fromJson(Map<String, dynamic> json) {
61
+ return ImportedGgufModelMetadata(
62
+ id: json['id'] as String,
63
+ name: json['name'] as String,
64
+ filePath: json['filePath'] as String,
65
+ fileSizeBytes: (json['fileSizeBytes'] as num).toInt(),
66
+ importedAt: DateTime.parse(json['importedAt'] as String),
67
+ );
68
+ }
69
+}
70
+
71
+class ImportedGgufModelRepository {
72
+ static const _storageKey = 'imported_gguf_models_v1';
73
+ static const _storageDirName = 'imported_gguf_models';
74
+
75
+ final SharedPreferences _prefs;
76
+ final Random _random;
77
+
78
+ ImportedGgufModelRepository(this._prefs, {Random? random})
79
+ : _random = random ?? Random.secure();
80
+
81
+ List<ImportedGgufModelMetadata> load() {
82
+ final encoded = _prefs.getString(_storageKey);
83
+ if (encoded == null || encoded.isEmpty) return [];
84
+
85
+ final decoded = json.decode(encoded) as List<dynamic>;
86
+ return decoded
87
+ .map(
88
+ (item) =>
89
+ ImportedGgufModelMetadata.fromJson(item as Map<String, dynamic>),
90
+ )
91
+ .toList();
92
+ }
93
+
94
+ Future<List<ImportedGgufModelMetadata>> loadExisting() async {
95
+ final models = load();
96
+ final existing = <ImportedGgufModelMetadata>[];
97
+
98
+ for (final model in models) {
99
+ if (await File(model.filePath).exists()) {
100
+ existing.add(model);
101
+ }
102
+ }
103
+
104
+ if (existing.length != models.length) {
105
+ await saveAll(existing);
106
+ }
107
+
108
+ return existing;
109
+ }
110
+
111
+ Future<ImportedGgufModelMetadata> importFromPath(String sourcePath) async {
112
+ final source = File(sourcePath);
113
+ if (!sourcePath.toLowerCase().endsWith('.gguf')) {
114
+ throw const FormatException(
115
+ 'Only GGUF models are supported for this import.',
116
+ );
117
+ }
118
+ if (!await source.exists()) {
119
+ throw FileSystemException(
120
+ 'Selected model file does not exist',
121
+ sourcePath,
122
+ );
123
+ }
124
+
125
+ final dir = await _modelsDirectory();
126
+ final originalName = p.basename(sourcePath);
127
+ final id = _createId(originalName);
128
+ final fileName = '$id-${_sanitizeFileName(originalName)}';
129
+ final target = File(p.join(dir.path, fileName));
130
+ await source.copy(target.path);
131
+
132
+ final metadata = ImportedGgufModelMetadata(
133
+ id: id,
134
+ name: _displayNameFromFileName(originalName),
135
+ filePath: target.path,
136
+ fileSizeBytes: await target.length(),
137
+ importedAt: DateTime.now(),
138
+ );
139
+
140
+ final current = load();
141
+ await saveAll([...current, metadata]);
142
+ return metadata;
143
+ }
144
+
145
+ Future<void> delete(String id) async {
146
+ final current = load();
147
+ final kept = <ImportedGgufModelMetadata>[];
148
+
149
+ for (final model in current) {
150
+ if (model.id == id) {
151
+ final file = File(model.filePath);
152
+ if (await file.exists()) {
153
+ await file.delete();
154
+ }
155
+ } else {
156
+ kept.add(model);
157
+ }
158
+ }
159
+
160
+ await saveAll(kept);
161
+ }
162
+
163
+ Future<void> saveAll(List<ImportedGgufModelMetadata> models) async {
164
+ final encoded = json.encode(models.map((m) => m.toJson()).toList());
165
+ await _prefs.setString(_storageKey, encoded);
166
+ }
167
+
168
+ Future<Directory> _modelsDirectory() async {
169
+ final supportDir = await getApplicationSupportDirectory();
170
+ final dir = Directory(p.join(supportDir.path, _storageDirName));
171
+ if (!await dir.exists()) {
172
+ await dir.create(recursive: true);
173
+ }
174
+ return dir;
175
+ }
176
+
177
+ String _createId(String fileName) {
178
+ final micros = DateTime.now().microsecondsSinceEpoch;
179
+ final suffix = _random.nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0');
180
+ return 'gguf-$micros-$suffix';
181
+ }
182
+
183
+ String _sanitizeFileName(String fileName) {
184
+ final sanitized = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_');
185
+ if (sanitized.toLowerCase().endsWith('.gguf')) return sanitized;
186
+ return '$sanitized.gguf';
187
+ }
188
+
189
+ String _displayNameFromFileName(String fileName) {
190
+ final withoutExtension = fileName.replaceFirst(
191
+ RegExp(r'\.gguf$', caseSensitive: false),
192
+ '',
193
+ );
194
+ return withoutExtension
195
+ .replaceAll(RegExp(r'[_-]+'), ' ')
196
+ .split(' ')
197
+ .where((part) => part.isNotEmpty)
198
+ .map((part) => '${part[0].toUpperCase()}${part.substring(1)}')
199
+ .join(' ');
200
+ }
201
+}
lib/features/on_device/data/models/on_device_model.dart
+25
-1
@@ -2,6 +2,10 @@ import 'package:flutter_gemma/flutter_gemma.dart';
2
3
import '../../../../core/models/enums.dart';
4
5
+enum OnDeviceModelRuntime { gemma, llamaCpp }
6
+
7
+enum OnDeviceModelFormat { litertlm, task, binary, gguf }
8
+
9
class OnDeviceModel {
10
final String id;
11
final String name;
@@ -19,6 +23,12 @@ class OnDeviceModel {
23
final String languagesLabel;
24
final String? backendNote;
25
final bool isCpuOnly;
26
+ final OnDeviceModelRuntime runtime;
27
+ final OnDeviceModelFormat format;
28
+ final String? localPath;
29
+ final DateTime? importedAt;
30
+ final bool isImported;
31
+ final bool requiresHuggingFaceToken;
32
33
const OnDeviceModel({
34
required this.id,
@@ -37,6 +47,12 @@ class OnDeviceModel {
47
required this.languagesLabel,
48
this.backendNote,
49
this.isCpuOnly = false,
50
+ this.runtime = OnDeviceModelRuntime.gemma,
51
+ this.format = OnDeviceModelFormat.litertlm,
52
+ this.localPath,
53
+ this.importedAt,
54
+ this.isImported = false,
55
+ this.requiresHuggingFaceToken = false,
56
});
57
58
String get fileSizeFormatted {
@@ -48,7 +64,14 @@ class OnDeviceModel {
64
return '${mb.toStringAsFixed(0)} MB';
65
}
66
51
- String get fileName => huggingFaceUrl.split('/').last;
67
+ String get fileName {
68
+ if (localPath != null && localPath!.isNotEmpty) {
69
+ return localPath!.split('/').last;
70
+ }
71
+ return huggingFaceUrl.split('/').last;
72
+ }
73
+
74
+ bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
75
76
ModelType get flutterGemmaModelType {
77
switch (id) {
@@ -161,6 +184,7 @@ class OnDeviceModel {
184
fileSizeBytes: 3221225472,
185
license: 'Gemma',
186
description: 'On-device multimodal chat and image analysis.',
187
+ requiresHuggingFaceToken: true,
188
minRamMb: 5120,
189
parameterLabel: 'E2B',
190
bestFor: 'On-device multimodal chat and image analysis',
lib/features/on_device/data/on_device_llama_service.dart
new
+264
@@ -0,0 +1,264 @@
1
+import 'dart:async';
2
+
3
+import 'package:llamadart/llamadart.dart' as llama;
4
+
5
+import '../../../core/logger/app_logger.dart';
6
+import '../../../core/models/enums.dart';
7
+import '../../chat/data/chat_service.dart';
8
+import '../../chat/data/models/chat_parameters.dart';
9
+import '../../chat/data/models/mcp_integration.dart';
10
+import '../../chat/data/models/message.dart' hide ToolCallData;
11
+import '../../chat/data/tools/tool_definition.dart';
12
+import '../../servers/data/models/server.dart';
13
+import 'models/on_device_model.dart';
14
+
15
+class OnDeviceLlamaService {
16
+ llama.LlamaEngine? _engine;
17
+ llama.ChatSession? _session;
18
+ String? _currentModelId;
19
+ int? _currentContextLength;
20
+ String? _currentSystemPrompt;
21
+ bool _isDisposed = false;
22
+
23
+ /// Tracks the most recent user message we processed, so we can detect
24
+ /// out-of-band edits or regenerations and rebuild the chat history.
25
+ String? _lastUserMessageId;
26
+ int _userMessageCount = 0;
27
+
28
+ String? get currentModelId => _currentModelId;
29
+ bool get isLoaded => _engine != null && !_isDisposed;
30
+
31
+ Future<void> loadModel(
32
+ OnDeviceModel model, {
33
+ int contextLength = 4096,
34
+ bool useGpu = false,
35
+ }) async {
36
+ if (_isDisposed) {
37
+ throw StateError('OnDeviceLlamaService has been disposed');
38
+ }
39
+ if (model.localPath == null || model.localPath!.isEmpty) {
40
+ throw StateError('Imported GGUF model is missing a local file path');
41
+ }
42
+
43
+ final gpuLayers = useGpu ? llama.ModelParams.maxGpuLayers : 0;
44
+ final alreadyLoaded = _engine != null && _currentModelId == model.id;
45
+ final contextChanged = _currentContextLength != contextLength;
46
+
47
+ if (alreadyLoaded && !contextChanged) {
48
+ return;
49
+ }
50
+
51
+ await unloadModel();
52
+
53
+ Log.info(
54
+ 'Loading GGUF model ${model.id} from ${model.localPath} '
55
+ '(context=$contextLength, gpuLayers=$gpuLayers)',
56
+ );
57
+ final engine = llama.LlamaEngine(llama.LlamaBackend());
58
+ await engine.loadModel(
59
+ model.localPath!,
60
+ modelParams: llama.ModelParams(
61
+ contextSize: contextLength,
62
+ gpuLayers: gpuLayers,
63
+ ),
64
+ );
65
+
66
+ _engine = engine;
67
+ _currentModelId = model.id;
68
+ _currentContextLength = contextLength;
69
+ _session = null;
70
+ _currentSystemPrompt = null;
71
+ _lastUserMessageId = null;
72
+ _userMessageCount = 0;
73
+ Log.info('GGUF model ${model.id} loaded successfully');
74
+ }
75
+
76
+ /// Resets the chat session so the next [sendMessage] starts from an empty
77
+ /// history. Useful when the caller knows the upstream message list has been
78
+ /// edited or regenerated outside of this service.
79
+ void resetSession() {
80
+ _session = null;
81
+ _currentSystemPrompt = null;
82
+ _lastUserMessageId = null;
83
+ _userMessageCount = 0;
84
+ }
85
+
86
+ Stream<ChatResponse> sendMessage({
87
+ required String modelId,
88
+ required List<Message> messages,
89
+ required ChatParameters params,
90
+ }) async* {
91
+ final engine = _engine;
92
+ if (engine == null || _currentModelId != modelId) {
93
+ yield const ChatResponse(
94
+ type: ChatResponseType.error,
95
+ content: 'GGUF model not loaded',
96
+ );
97
+ yield const ChatResponse(type: ChatResponseType.done);
98
+ return;
99
+ }
100
+
101
+ final userMessages = messages
102
+ .where((m) => m.role == MessageRole.user)
103
+ .toList(growable: false);
104
+ if (userMessages.isEmpty) {
105
+ yield const ChatResponse(
106
+ type: ChatResponseType.error,
107
+ content: 'GGUF chat requires a user message.',
108
+ );
109
+ yield const ChatResponse(type: ChatResponseType.done);
110
+ return;
111
+ }
112
+
113
+ final latestUserMessage = userMessages.last;
114
+ final systemPrompt = params.systemPrompt?.isNotEmpty == true
115
+ ? params.systemPrompt
116
+ : null;
117
+ final sessionContextChanged =
118
+ _session != null && _currentContextLength != params.contextLength;
119
+
120
+ if (_session == null ||
121
+ _currentSystemPrompt != systemPrompt ||
122
+ sessionContextChanged) {
123
+ _session = llama.ChatSession(
124
+ engine,
125
+ maxContextTokens: params.contextLength,
126
+ systemPrompt: systemPrompt,
127
+ );
128
+ _currentSystemPrompt = systemPrompt;
129
+ _currentContextLength = params.contextLength;
130
+ _userMessageCount = 0;
131
+ _lastUserMessageId = null;
132
+ } else if (userMessages.length < _userMessageCount ||
133
+ (_lastUserMessageId != null &&
134
+ latestUserMessage.id != _lastUserMessageId)) {
135
+ // Upstream history diverged from our internal tracking (edit, retry,
136
+ // regeneration). Discard the cached history and rebuild it from the
137
+ // caller-supplied messages on the next create() call.
138
+ _session!.reset(keepSystemPrompt: true);
139
+ _userMessageCount = 0;
140
+ _lastUserMessageId = null;
141
+ }
142
+
143
+ final text = latestUserMessage.content.trim();
144
+ if (text.isEmpty) {
145
+ yield const ChatResponse(
146
+ type: ChatResponseType.error,
147
+ content: 'GGUF chat requires a text prompt.',
148
+ );
149
+ yield const ChatResponse(type: ChatResponseType.done);
150
+ return;
151
+ }
152
+
153
+ try {
154
+ await for (final chunk in _session!.create(
155
+ [llama.LlamaTextContent(text)],
156
+ params: llama.GenerationParams(
157
+ maxTokens: params.maxTokens,
158
+ temp: params.temperature,
159
+ topP: params.topP,
160
+ topK: params.topK ?? 40,
161
+ minP: params.minP ?? 0.0,
162
+ penalty: params.repeatPenalty ?? 1.1,
163
+ ),
164
+ )) {
165
+ if (chunk.choices.isEmpty) continue;
166
+ final delta = chunk.choices.first.delta;
167
+ final reasoning = delta.thinking;
168
+ if (reasoning != null && reasoning.isNotEmpty) {
169
+ yield ChatResponse(
170
+ type: ChatResponseType.reasoning,
171
+ reasoningContent: reasoning,
172
+ );
173
+ }
174
+ final content = delta.content;
175
+ if (content != null && content.isNotEmpty) {
176
+ yield ChatResponse(type: ChatResponseType.message, content: content);
177
+ }
178
+ }
179
+ _userMessageCount = userMessages.length;
180
+ _lastUserMessageId = latestUserMessage.id;
181
+ yield const ChatResponse(type: ChatResponseType.done);
182
+ } catch (e) {
183
+ Log.error('GGUF inference error: $e');
184
+ yield ChatResponse(
185
+ type: ChatResponseType.error,
186
+ content: 'GGUF inference error: ${e.toString()}',
187
+ );
188
+ yield const ChatResponse(type: ChatResponseType.done);
189
+ }
190
+ }
191
+
192
+ /// Cancels the currently running inference, if any. Safe to call when no
193
+ /// generation is in progress.
194
+ void cancelGeneration() {
195
+ final engine = _engine;
196
+ if (engine == null) return;
197
+ try {
198
+ engine.backend.cancelGeneration();
199
+ } catch (e) {
200
+ Log.error('Failed to cancel GGUF generation: $e');
201
+ }
202
+ }
203
+
204
+ Future<void> unloadModel() async {
205
+ final engine = _engine;
206
+ _engine = null;
207
+ _session = null;
208
+ _currentModelId = null;
209
+ _currentContextLength = null;
210
+ _currentSystemPrompt = null;
211
+ _lastUserMessageId = null;
212
+ _userMessageCount = 0;
213
+ if (engine != null) {
214
+ try {
215
+ await engine.dispose();
216
+ } catch (e) {
217
+ Log.error('Error disposing GGUF engine: $e');
218
+ }
219
+ }
220
+ }
221
+
222
+ void dispose() {
223
+ _isDisposed = true;
224
+ unawaited(unloadModel());
225
+ }
226
+}
227
+
228
+class OnDeviceLlamaChatService implements ChatService {
229
+ OnDeviceLlamaChatService(this._llamaService);
230
+
231
+ final OnDeviceLlamaService _llamaService;
232
+ bool _isCancelled = false;
233
+
234
+ @override
235
+ Stream<ChatResponse> sendMessage({
236
+ required Server server,
237
+ required String modelId,
238
+ required List<Message> messages,
239
+ required ChatParameters params,
240
+ List<McpIntegration>? integrations,
241
+ List<ToolDefinition>? tools,
242
+ String? previousResponseId,
243
+ }) async* {
244
+ _isCancelled = false;
245
+ await for (final response in _llamaService.sendMessage(
246
+ modelId: modelId,
247
+ messages: messages,
248
+ params: params,
249
+ )) {
250
+ if (_isCancelled) break;
251
+ yield response;
252
+ if (response.type == ChatResponseType.done ||
253
+ response.type == ChatResponseType.error) {
254
+ break;
255
+ }
256
+ }
257
+ }
258
+
259
+ @override
260
+ void cancelStream() {
261
+ _isCancelled = true;
262
+ _llamaService.cancelGeneration();
263
+ }
264
+}
lib/features/on_device/providers/foreground_download_providers.dart
+16
-1
@@ -4,6 +4,7 @@ import 'dart:convert';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
import '../../../core/logger/app_logger.dart';
7
+import '../../../core/providers/app_providers.dart';
8
import '../../../core/providers/review_prompt_providers.dart';
9
import '../../../core/providers/storage_providers.dart';
10
import '../data/models/download_progress_info.dart';
@@ -60,13 +61,22 @@ class ForegroundDownloadNotifier
61
}
62
}
63
63
- Future<void> startDownload(String modelId) async {
64
+ Future<String?> startDownload(String modelId) async {
65
final models = ref.read(onDeviceModelsProvider);
66
final model = models.firstWhere(
67
(m) => m.id == modelId,
68
orElse: () => throw Exception('Model not found: $modelId'),
69
);
70
71
+ if (model.requiresHuggingFaceToken) {
72
+ final hfToken = ref.read(
73
+ settingsProvider.select((s) => s.huggingFaceToken),
74
+ );
75
+ if (hfToken == null || hfToken.isEmpty) {
76
+ return 'missing_huggingface_token';
77
+ }
78
+ }
79
+
80
_activeDownloads[modelId] = true;
81
_downloadStartTimes[modelId] = DateTime.now();
82
@@ -82,10 +92,14 @@ class ForegroundDownloadNotifier
92
_saveState();
93
94
final gemmaService = ref.read(onDeviceGemmaServiceProvider);
95
+ final hfToken = ref.read(
96
+ settingsProvider.select((s) => s.huggingFaceToken),
97
+ );
98
99
try {
100
await gemmaService.installModel(
101
model,
102
+ token: (hfToken != null && hfToken.isNotEmpty) ? hfToken : null,
103
onProgress: (progress) {
104
if (_activeDownloads[modelId] != true) return;
105
@@ -157,6 +171,7 @@ class ForegroundDownloadNotifier
171
_activeDownloads.remove(modelId);
172
_downloadStartTimes.remove(modelId);
173
}
174
+ return null;
175
}
176
177
void _removeCompletedDownload(String modelId) {
lib/features/on_device/providers/on_device_providers.dart
+147
-22
@@ -5,11 +5,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
import '../../../core/logger/app_logger.dart';
7
import '../../../core/models/enums.dart';
8
+import '../../../core/providers/app_providers.dart';
9
import '../../../core/providers/chat_background_service_provider.dart';
10
import '../../../core/providers/review_prompt_providers.dart';
11
+import '../../../core/providers/storage_providers.dart';
12
+import '../data/imported_gguf_model_repository.dart';
13
import '../data/models/on_device_model.dart';
14
import '../data/notification_permission_service.dart';
15
import '../data/on_device_gemma_service.dart';
16
+import '../data/on_device_llama_service.dart';
17
18
final notificationPermissionServiceProvider =
19
Provider<NotificationPermissionService>((ref) {
@@ -22,19 +26,37 @@ final onDeviceGemmaServiceProvider = Provider<OnDeviceGemmaService>((ref) {
26
return service;
27
});
28
29
+final onDeviceLlamaServiceProvider = Provider<OnDeviceLlamaService>((ref) {
30
+ final service = OnDeviceLlamaService();
31
+ ref.onDispose(() => service.dispose());
32
+ return service;
33
+});
34
+
35
+final importedGgufModelRepositoryProvider =
36
+ Provider<ImportedGgufModelRepository>((ref) {
37
+ return ImportedGgufModelRepository(ref.read(sharedPreferencesProvider));
38
+ });
39
+
40
+final importedGgufModelsProvider =
41
+ NotifierProvider<ImportedGgufModelsNotifier, List<OnDeviceModel>>(() {
42
+ return ImportedGgufModelsNotifier();
43
+ });
44
+
45
final onDeviceEngineProvider =
46
NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
47
return OnDeviceEngineNotifier();
48
});
49
50
final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
31
- return OnDeviceModel.curatedModels;
51
+ final imported = ref.watch(importedGgufModelsProvider);
52
+ return [...OnDeviceModel.curatedModels, ...imported];
53
});
54
55
final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
56
final gemmaService = ref.read(onDeviceGemmaServiceProvider);
57
final installed = await gemmaService.getInstalledModelIds();
37
- return installed.toSet();
58
+ final imported = ref.watch(importedGgufModelsProvider);
59
+ return {...installed, ...imported.map((model) => model.id)};
60
});
61
62
final onDeviceModelStateProvider =
@@ -46,33 +68,89 @@ final onDeviceModelStateProvider =
68
});
69
70
class OnDeviceEngineState {
71
+ static const _unset = Object();
72
+
73
final OnDeviceEngineStatus status;
74
final String? loadedModelId;
75
+ final OnDeviceModelRuntime? loadedRuntime;
76
final PreferredBackend? backend;
77
final String? error;
78
79
const OnDeviceEngineState({
80
this.status = OnDeviceEngineStatus.notLoaded,
81
this.loadedModelId,
82
+ this.loadedRuntime,
83
this.backend,
84
this.error,
85
});
86
87
OnDeviceEngineState copyWith({
88
OnDeviceEngineStatus? status,
63
- String? loadedModelId,
64
- PreferredBackend? backend,
65
- String? error,
89
+ Object? loadedModelId = _unset,
90
+ Object? loadedRuntime = _unset,
91
+ Object? backend = _unset,
92
+ Object? error = _unset,
93
}) {
94
return OnDeviceEngineState(
95
status: status ?? this.status,
69
- loadedModelId: loadedModelId ?? this.loadedModelId,
70
- backend: backend ?? this.backend,
71
- error: error ?? this.error,
96
+ loadedModelId: identical(loadedModelId, _unset)
97
+ ? this.loadedModelId
98
+ : loadedModelId as String?,
99
+ loadedRuntime: identical(loadedRuntime, _unset)
100
+ ? this.loadedRuntime
101
+ : loadedRuntime as OnDeviceModelRuntime?,
102
+ backend: identical(backend, _unset)
103
+ ? this.backend
104
+ : backend as PreferredBackend?,
105
+ error: identical(error, _unset) ? this.error : error as String?,
106
);
107
}
108
}
109
110
+class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
111
+ ImportedGgufModelRepository get _repository =>
112
+ ref.read(importedGgufModelRepositoryProvider);
113
+
114
+ @override
115
+ List<OnDeviceModel> build() {
116
+ return _repository.load().map((model) => model.toOnDeviceModel()).toList();
117
+ }
118
+
119
+ Future<void> importModel(String sourcePath) async {
120
+ final metadata = await _repository.importFromPath(sourcePath);
121
+ state = [...state, metadata.toOnDeviceModel()];
122
+ }
123
+
124
+ Future<void> deleteModel(String modelId) async {
125
+ await _repository.delete(modelId);
126
+ state = state.where((model) => model.id != modelId).toList();
127
+ }
128
+
129
+ /// Removes a single missing model from the list. Used when we try to load
130
+ /// a specific imported GGUF and discover its file is gone, so other valid
131
+ /// imports are not collateral damage.
132
+ Future<void> removeMissingModel(String modelId) async {
133
+ final hadModel = state.any((m) => m.id == modelId);
134
+ if (!hadModel) return;
135
+ await _repository.delete(modelId);
136
+ state = state.where((model) => model.id != modelId).toList();
137
+ }
138
+
139
+ /// Prunes any imported models whose backing files have disappeared from
140
+ /// disk. Intended to be called explicitly (e.g. when entering the model
141
+ /// manager screen) rather than from `build()` so it never runs against a
142
+ /// disposed provider container.
143
+ Future<void> pruneMissing() async {
144
+ final existing = await _repository.loadExisting();
145
+ final keptIds = existing.map((m) => m.id).toSet();
146
+ if (keptIds.length == state.length &&
147
+ state.every((m) => keptIds.contains(m.id))) {
148
+ return;
149
+ }
150
+ state = existing.map((m) => m.toOnDeviceModel()).toList();
151
+ }
152
+}
153
+
154
class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
155
@override
156
OnDeviceEngineState build() {
@@ -81,38 +159,75 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
159
160
OnDeviceGemmaService get _gemmaService =>
161
ref.read(onDeviceGemmaServiceProvider);
162
+ OnDeviceLlamaService get _llamaService =>
163
+ ref.read(onDeviceLlamaServiceProvider);
164
165
Future<void> loadModel(String modelId, PreferredBackend backend) async {
86
- state = state.copyWith(status: OnDeviceEngineStatus.loading, error: null);
166
+ final models = ref.read(onDeviceModelsProvider);
167
+ final model = models.firstWhere(
168
+ (m) => m.id == modelId,
169
+ orElse: () => throw Exception('Model not found: $modelId'),
170
+ );
171
+
172
+ state = state.copyWith(
173
+ status: OnDeviceEngineStatus.loading,
174
+ loadedModelId: modelId,
175
+ loadedRuntime: model.runtime,
176
+ error: null,
177
+ );
178
179
// Protect the heavy native model-load from Android CPU throttling
180
final bgService = ref.read(chatBackgroundServiceProvider);
181
await bgService.start();
182
183
try {
93
- final model = OnDeviceModel.curatedModels.firstWhere(
94
- (m) => m.id == modelId,
95
- orElse: () => throw Exception('Model not found: $modelId'),
96
- );
184
final effectiveBackend = model.isCpuOnly ? PreferredBackend.cpu : backend;
185
186
// Yield control to the event loop so the UI has time to draw the loading indicator
187
// and stabilize before the native platform thread begins heavy model loading.
188
await Future.delayed(const Duration(milliseconds: 100));
102
- final isInstalled = await _gemmaService.isModelInstalled(modelId);
103
- if (!isInstalled) {
104
- state = state.copyWith(
105
- status: OnDeviceEngineStatus.error,
106
- error: 'Model not found. Please download it first.',
189
+ if (model.runtime == OnDeviceModelRuntime.llamaCpp) {
190
+ final file = File(model.localPath ?? '');
191
+ if (!await file.exists()) {
192
+ await ref
193
+ .read(importedGgufModelsProvider.notifier)
194
+ .removeMissingModel(modelId);
195
+ state = state.copyWith(
196
+ status: OnDeviceEngineStatus.error,
197
+ loadedModelId: null,
198
+ loadedRuntime: null,
199
+ error: 'Imported GGUF file is missing. Please re-import it.',
200
+ );
201
+ return;
202
+ }
203
+
204
+ final settings = ref.read(settingsProvider);
205
+ await _gemmaService.unloadModel();
206
+ await _llamaService.loadModel(
207
+ model,
208
+ contextLength: settings.contextLength,
209
+ useGpu: !model.isCpuOnly && effectiveBackend == PreferredBackend.gpu,
210
);
108
- return;
109
- }
211
+ } else {
212
+ final isInstalled = await _gemmaService.isModelInstalled(modelId);
213
+ if (!isInstalled) {
214
+ state = state.copyWith(
215
+ status: OnDeviceEngineStatus.error,
216
+ loadedModelId: null,
217
+ loadedRuntime: null,
218
+ error: 'Model not found. Please download it first.',
219
+ );
220
+ return;
221
+ }
222
111
- await _gemmaService.loadModel(modelId, effectiveBackend);
223
+ await _llamaService.unloadModel();
224
+ await _gemmaService.loadModel(modelId, effectiveBackend);
225
+ }
226
227
state = state.copyWith(
228
status: OnDeviceEngineStatus.loaded,
229
loadedModelId: modelId,
230
+ loadedRuntime: model.runtime,
231
backend: effectiveBackend,
232
);
233
@@ -131,6 +246,8 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
246
Log.error('Failed to load model $modelId: $e');
247
state = state.copyWith(
248
status: OnDeviceEngineStatus.error,
249
+ loadedModelId: null,
250
+ loadedRuntime: null,
251
error: 'Failed to load model: ${e.toString()}',
252
);
253
} finally {
@@ -141,12 +258,20 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
258
Future<void> unloadModel() async {
259
try {
260
await _gemmaService.unloadModel();
144
- } catch (_) {}
261
+ } catch (e) {
262
+ Log.error('Failed to unload gemma model: $e');
263
+ }
264
+ try {
265
+ await _llamaService.unloadModel();
266
+ } catch (e) {
267
+ Log.error('Failed to unload llama.cpp model: $e');
268
+ }
269
state = const OnDeviceEngineState();
270
}
271
272
Future<void> disposeService() async {
273
_gemmaService.dispose();
274
+ _llamaService.dispose();
275
state = const OnDeviceEngineState();
276
}
277
}
lib/features/on_device/views/model_manager_screen.dart
+200
-90
@@ -1,12 +1,16 @@
1
import 'dart:io';
2
3
+import 'package:file_picker/file_picker.dart';
4
+import 'package:flutter/foundation.dart';
5
import 'package:flutter/material.dart';
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
7
+import 'package:go_router/go_router.dart';
8
import 'package:localmind/l10n/app_localizations.dart';
9
import 'package:shadcn_ui/shadcn_ui.dart';
10
11
import '../../../core/models/enums.dart';
12
import '../../../core/providers/app_providers.dart';
13
+import '../../../core/routes/app_routes.dart';
14
import '../../on_device/data/models/on_device_model.dart';
15
import '../../on_device/data/models/download_progress_info.dart';
16
import '../../on_device/data/models/download_status.dart';
@@ -31,11 +35,19 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
35
ref.listen(onDeviceEngineProvider, (prev, next) {
36
if (next.status == OnDeviceEngineStatus.loaded &&
37
prev?.status == OnDeviceEngineStatus.loading) {
38
+ final loadedId = next.loadedModelId;
39
+ final loadedName = loadedId == null
40
+ ? 'Unknown'
41
+ : models
42
+ .where((m) => m.id == loadedId)
43
+ .map((m) => m.name)
44
+ .followedBy([loadedId])
45
+ .first;
46
ScaffoldMessenger.of(context).showSnackBar(
47
SnackBar(
48
content: Text(
49
l10n.model_loaded(
38
- next.loadedModelId ?? 'Unknown',
50
+ loadedName,
51
next.backend?.name ?? 'CPU',
52
),
53
),
@@ -55,28 +67,29 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
67
top: topPadding + 8,
68
bottom: 16,
69
),
58
- decoration: BoxDecoration(
59
- color: theme.brightness == Brightness.dark
60
- ? const Color(0xFF0A0A0A)
61
- : const Color(0xFFFAFAFA),
62
- border: Border(
63
- bottom: BorderSide(
64
- color: theme.brightness == Brightness.dark
65
- ? const Color(0xFF2A2A2A)
66
- : const Color(0xFFE5E5E5),
67
- ),
70
+ decoration: BoxDecoration(
71
+ color: theme.brightness == Brightness.dark
72
+ ? const Color(0xFF0A0A0A)
73
+ : const Color(0xFFFAFAFA),
74
+ border: Border(
75
+ bottom: BorderSide(
76
+ color: theme.brightness == Brightness.dark
77
+ ? const Color(0xFF2A2A2A)
78
+ : const Color(0xFFE5E5E5),
79
),
80
),
70
- child: Row(
71
- children: [
72
- Builder(
73
- builder: (context) => IconButton(
74
- icon: const Icon(Icons.menu),
75
- onPressed: () => Scaffold.of(context).openDrawer(),
76
- ),
81
+ ),
82
+ child: Row(
83
+ children: [
84
+ Builder(
85
+ builder: (context) => IconButton(
86
+ icon: const Icon(Icons.menu),
87
+ onPressed: () => Scaffold.of(context).openDrawer(),
88
),
78
- const SizedBox(width: 8),
79
- Text(
89
+ ),
90
+ const SizedBox(width: 8),
91
+ Expanded(
92
+ child: Text(
93
l10n.on_device_models_title,
94
style: TextStyle(
95
fontSize: 20,
@@ -86,79 +99,103 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
99
: Colors.black,
100
),
101
),
89
- ],
90
- ),
102
+ ),
103
+ if (defaultTargetPlatform == TargetPlatform.android ||
104
+ defaultTargetPlatform == TargetPlatform.iOS)
105
+ ShadButton.outline(
106
+ size: ShadButtonSize.sm,
107
+ onPressed: () => _importGguf(context, ref),
108
+ child: const Text('Import GGUF'),
109
+ ),
110
+ ],
111
),
92
- Expanded(
93
- child: ListView(
94
- padding: const EdgeInsets.all(16),
95
- children: [
96
- _buildMemoryInfo(context, ref, deviceMemoryAsync),
97
- if (engineState.status == OnDeviceEngineStatus.loaded)
98
- Container(
99
- padding: const EdgeInsets.all(12),
100
- margin: const EdgeInsets.only(bottom: 16),
101
- decoration: BoxDecoration(
102
- color: Colors.green.withValues(alpha: 0.1),
103
- borderRadius: BorderRadius.circular(8),
104
- border: Border.all(color: Colors.green),
105
- ),
106
- child: Row(
107
- children: [
108
- const Icon(Icons.check_circle, color: Colors.green, size: 20),
109
- const SizedBox(width: 8),
110
- Expanded(
111
- child: Text(
112
- l10n.model_loaded(
113
- engineState.loadedModelId ?? 'Unknown',
114
- engineState.backend?.name ?? 'CPU',
112
+ ),
113
+ Expanded(
114
+ child: ListView(
115
+ padding: const EdgeInsets.all(16),
116
+ children: [
117
+ _buildMemoryInfo(context, ref, deviceMemoryAsync),
118
+ if (engineState.status == OnDeviceEngineStatus.loaded)
119
+ Builder(
120
+ builder: (context) {
121
+ final loadedId = engineState.loadedModelId;
122
+ final loadedName = loadedId == null
123
+ ? 'Unknown'
124
+ : models
125
+ .where((m) => m.id == loadedId)
126
+ .map((m) => m.name)
127
+ .followedBy([loadedId])
128
+ .first;
129
+ return Container(
130
+ padding: const EdgeInsets.all(12),
131
+ margin: const EdgeInsets.only(bottom: 16),
132
+ decoration: BoxDecoration(
133
+ color: Colors.green.withValues(alpha: 0.1),
134
+ borderRadius: BorderRadius.circular(8),
135
+ border: Border.all(color: Colors.green),
136
+ ),
137
+ child: Row(
138
+ children: [
139
+ const Icon(
140
+ Icons.check_circle,
141
+ color: Colors.green,
142
+ size: 20,
143
+ ),
144
+ const SizedBox(width: 8),
145
+ Expanded(
146
+ child: Text(
147
+ l10n.model_loaded(
148
+ loadedName,
149
+ engineState.backend?.name ?? 'CPU',
150
+ ),
151
+ style: const TextStyle(color: Colors.green),
152
),
116
- style: const TextStyle(color: Colors.green),
153
),
118
- ),
119
- ],
120
- ),
121
- )
122
- else if (engineState.status == OnDeviceEngineStatus.loading)
123
- const Padding(
124
- padding: EdgeInsets.only(bottom: 16),
125
- child: LinearProgressIndicator(),
126
- )
127
- else if (engineState.status == OnDeviceEngineStatus.error)
128
- Container(
129
- padding: const EdgeInsets.all(12),
130
- margin: const EdgeInsets.only(bottom: 16),
131
- decoration: BoxDecoration(
132
- color: Colors.red.withValues(alpha: 0.1),
133
- borderRadius: BorderRadius.circular(8),
134
- border: Border.all(color: Colors.red),
135
- ),
136
- child: Text(
137
- engineState.error ?? l10n.unknown_error,
138
- style: const TextStyle(color: Colors.red),
139
- ),
154
+ ],
155
+ ),
156
+ );
157
+ },
158
+ )
159
+ else if (engineState.status == OnDeviceEngineStatus.loading)
160
+ const Padding(
161
+ padding: EdgeInsets.only(bottom: 16),
162
+ child: LinearProgressIndicator(),
163
+ )
164
+ else if (engineState.status == OnDeviceEngineStatus.error)
165
+ Container(
166
+ padding: const EdgeInsets.all(12),
167
+ margin: const EdgeInsets.only(bottom: 16),
168
+ decoration: BoxDecoration(
169
+ color: Colors.red.withValues(alpha: 0.1),
170
+ borderRadius: BorderRadius.circular(8),
171
+ border: Border.all(color: Colors.red),
172
),
141
- Text(
142
- l10n.available_models,
143
- style: theme.textTheme.titleMedium?.copyWith(
144
- fontWeight: FontWeight.bold,
173
+ child: Text(
174
+ engineState.error ?? l10n.unknown_error,
175
+ style: const TextStyle(color: Colors.red),
176
),
177
),
147
- const SizedBox(height: 12),
148
- ...models.map(
149
- (model) => _ModelCard(
150
- model: model,
151
- theme: theme,
152
- downloadedAsync: downloadedAsync,
153
- downloadProgress: downloadProgress[model.id],
154
- engineState: engineState,
155
- deviceMemory: deviceMemoryAsync.value,
156
- ),
178
+ Text(
179
+ l10n.available_models,
180
+ style: theme.textTheme.titleMedium?.copyWith(
181
+ fontWeight: FontWeight.bold,
182
),
158
- const SizedBox(height: 24),
159
- ],
160
- ),
183
+ ),
184
+ const SizedBox(height: 12),
185
+ ...models.map(
186
+ (model) => _ModelCard(
187
+ model: model,
188
+ theme: theme,
189
+ downloadedAsync: downloadedAsync,
190
+ downloadProgress: downloadProgress[model.id],
191
+ engineState: engineState,
192
+ deviceMemory: deviceMemoryAsync.value,
193
+ ),
194
+ ),
195
+ const SizedBox(height: 24),
196
+ ],
197
),
198
+ ),
199
],
200
);
201
}
@@ -401,6 +438,36 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
438
}
439
return '$mb MB';
440
}
441
+
442
+ Future<void> _importGguf(BuildContext context, WidgetRef ref) async {
443
+ final messenger = ScaffoldMessenger.of(context);
444
+ try {
445
+ final result = await FilePicker.pickFiles(
446
+ type: FileType.custom,
447
+ allowedExtensions: const ['gguf'],
448
+ );
449
+ if (result == null || result.files.isEmpty) return;
450
+
451
+ final path = result.files.single.path;
452
+ if (path == null || !path.toLowerCase().endsWith('.gguf')) {
453
+ messenger.showSnackBar(
454
+ const SnackBar(
455
+ content: Text('Only GGUF models are supported for this import.'),
456
+ ),
457
+ );
458
+ return;
459
+ }
460
+
461
+ await ref.read(importedGgufModelsProvider.notifier).importModel(path);
462
+ messenger.showSnackBar(
463
+ const SnackBar(content: Text('GGUF model imported.')),
464
+ );
465
+ } catch (e) {
466
+ messenger.showSnackBar(
467
+ SnackBar(content: Text('Failed to import GGUF model: $e')),
468
+ );
469
+ }
470
+ }
471
}
472
473
class _MemoryStat extends StatelessWidget {
@@ -551,6 +618,25 @@ class _ModelCard extends ConsumerWidget {
618
),
619
),
620
),
621
+ if (model.isImported)
622
+ Container(
623
+ margin: const EdgeInsets.only(left: 6),
624
+ padding: const EdgeInsets.symmetric(
625
+ horizontal: 8,
626
+ vertical: 2,
627
+ ),
628
+ decoration: BoxDecoration(
629
+ color: Colors.blue.withValues(alpha: 0.1),
630
+ borderRadius: BorderRadius.circular(4),
631
+ ),
632
+ child: Text(
633
+ 'Imported',
634
+ style: theme.textTheme.labelSmall?.copyWith(
635
+ color: Colors.blue,
636
+ fontWeight: FontWeight.bold,
637
+ ),
638
+ ),
639
+ ),
640
],
641
),
642
if (!Platform.isIOS &&
@@ -598,7 +684,7 @@ class _ModelCard extends ConsumerWidget {
684
),
685
),
686
const SizedBox(height: 8),
601
- _buildCapabilityChips(),
687
+ _buildCapabilityChips(context),
688
const SizedBox(height: 8),
689
Row(
690
children: [
@@ -632,13 +718,18 @@ class _ModelCard extends ConsumerWidget {
718
);
719
}
720
635
- Widget _buildCapabilityChips() {
721
+ Widget _buildCapabilityChips(BuildContext context) {
722
+ final l10n = AppLocalizations.of(context)!;
723
final chips = <String>[
724
+ if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
725
+ if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
726
if (model.supportsFunctionCalling) 'Tools',
727
if (model.supportsThinking) 'Thinking',
728
if (model.supportsVision) 'Vision',
729
model.languagesLabel,
730
if (model.backendNote != null) model.backendNote!,
731
+ if (model.requiresHuggingFaceToken)
732
+ l10n.model_requires_huggingface_token,
733
];
734
735
return Wrap(
@@ -866,9 +957,22 @@ class _ModelCard extends ConsumerWidget {
957
}
958
}
959
869
- await ref
960
+ final result = await ref
961
.read(foregroundDownloadNotifierProvider.notifier)
962
.startDownload(model.id);
963
+
964
+ if (result == 'missing_huggingface_token' && context.mounted) {
965
+ ScaffoldMessenger.of(context).showSnackBar(
966
+ SnackBar(
967
+ content: Text(l10n.model_missing_huggingface_token),
968
+ duration: const Duration(seconds: 6),
969
+ action: SnackBarAction(
970
+ label: l10n.settings_title,
971
+ onPressed: () => context.push(AppRoutes.settings),
972
+ ),
973
+ ),
974
+ );
975
+ }
976
}
977
978
Future<void> _loadModel(BuildContext context, WidgetRef ref) async {
@@ -950,8 +1054,14 @@ class _ModelCard extends ConsumerWidget {
1054
await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1055
}
1056
953
- final gemmaService = ref.read(onDeviceGemmaServiceProvider);
954
- await gemmaService.deleteModel(model.id);
1057
+ if (model.isImported) {
1058
+ await ref
1059
+ .read(importedGgufModelsProvider.notifier)
1060
+ .deleteModel(model.id);
1061
+ } else {
1062
+ final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1063
+ await gemmaService.deleteModel(model.id);
1064
+ }
1065
ref.invalidate(downloadedModelsProvider);
1066
}
1067
}
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+17
-3
@@ -404,7 +404,7 @@ class _ModelCard extends ConsumerWidget {
404
),
405
),
406
const SizedBox(height: 8),
407
- _buildCapabilityChips(),
407
+ _buildCapabilityChips(context),
408
const SizedBox(height: 8),
409
Row(
410
children: [
@@ -516,13 +516,16 @@ class _ModelCard extends ConsumerWidget {
516
);
517
}
518
519
- Widget _buildCapabilityChips() {
519
+ Widget _buildCapabilityChips(BuildContext context) {
520
+ final l10n = AppLocalizations.of(context)!;
521
final chips = <String>[
522
if (model.supportsFunctionCalling) 'Tools',
523
if (model.supportsThinking) 'Thinking',
524
if (model.supportsVision) 'Vision',
525
model.languagesLabel,
526
if (model.backendNote != null) model.backendNote!,
527
+ if (model.requiresHuggingFaceToken)
528
+ l10n.model_requires_huggingface_token,
529
];
530
531
return Wrap(
@@ -563,9 +566,20 @@ class _ModelCard extends ConsumerWidget {
566
}
567
}
568
566
- await ref
569
+ final result = await ref
570
.read(foregroundDownloadNotifierProvider.notifier)
571
.startDownload(model.id);
572
+
573
+ if (result == 'missing_huggingface_token' && context.mounted) {
574
+ ScaffoldMessenger.of(context).showSnackBar(
575
+ SnackBar(
576
+ content: Text(
577
+ AppLocalizations.of(context)!.model_missing_huggingface_token,
578
+ ),
579
+ duration: const Duration(seconds: 6),
580
+ ),
581
+ );
582
+ }
583
}
584
585
Future<bool> _showRamWarning(BuildContext context) async {
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+3
-3
@@ -150,7 +150,7 @@ class _OnboardingServerTypeScreenState
150
_buildServerCard(
151
type: ServerType.onDevice,
152
title: l10n.server_type_on_device,
153
- subtitle: l10n.server_type_on_device_sub,
153
+ subtitle: l10n.server_type_on_device_desc,
154
iconWidget: Icon(
155
Icons.phone_android_rounded,
156
color: _selectedType == ServerType.onDevice
@@ -162,7 +162,7 @@ class _OnboardingServerTypeScreenState
162
_buildServerCard(
163
type: ServerType.lmStudio,
164
title: l10n.server_type_lm_studio,
165
- subtitle: l10n.server_type_lm_studio_sub,
165
+ subtitle: l10n.server_type_lm_studio_desc,
166
iconWidget: Icon(
167
Icons.terminal_rounded,
168
color: _selectedType == ServerType.lmStudio
@@ -188,7 +188,7 @@ class _OnboardingServerTypeScreenState
188
_buildServerCard(
189
type: ServerType.ollama,
190
title: l10n.server_type_ollama,
191
- subtitle: l10n.server_type_ollama_sub,
191
+ subtitle: l10n.server_type_ollama_desc,
192
iconWidget: Icon(
193
Icons.smart_toy_rounded,
194
color: _selectedType == ServerType.ollama
lib/features/servers/data/server_api_service.dart
+12
-12
@@ -110,12 +110,10 @@ class ServerApiService {
110
}
111
break;
112
case ServerType.ollama:
113
- await _dio.post(
114
- server.loadModelEndpoint,
115
- data: {'model': modelId},
116
- options: Options(headers: _getAuthHeaders(server)),
117
- );
118
- break;
113
+ // Ollama auto-loads models on /api/chat. Calling /api/generate here
114
+ // would trigger an auto-pull (download) when the model is not already
115
+ // present on the Ollama server, which is not what the user wants.
116
+ return;
117
case ServerType.openRouter:
118
case ServerType.onDevice:
119
break;
@@ -142,11 +140,9 @@ class ServerApiService {
140
return null;
141
}
142
case ServerType.ollama:
145
- await _dio.post(
146
- server.loadModelEndpoint,
147
- data: {'model': modelId},
148
- options: Options(headers: _getAuthHeaders(server)),
149
- );
143
+ // See note in loadModel: Ollama auto-loads on /api/chat, so we do not
144
+ // issue an explicit /api/generate load (which would auto-pull the
145
+ // model if missing on the remote).
146
return null;
147
case ServerType.openRouter:
148
case ServerType.onDevice:
@@ -180,7 +176,11 @@ class ServerApiService {
176
case ServerType.ollama:
177
await _dio.post(
178
server.unloadModelEndpoint,
183
- data: {'model': modelId, 'keep_alive': 0},
179
+ data: {
180
+ 'model': modelId,
181
+ 'keep_alive': 0,
182
+ 'prompt': '',
183
+ },
184
options: Options(headers: _getAuthHeaders(server)),
185
);
186
break;
lib/features/servers/providers/server_providers.dart
+14
-5
@@ -115,9 +115,6 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
115
ref,
116
serverId,
117
) async {
118
- final cached = _modelCache.get(serverId);
119
- if (cached != null) return cached;
120
-
118
final serversAsync = ref.watch(serversProvider);
119
final servers = serversAsync.value ?? [];
120
final server = servers.firstWhere(
@@ -127,7 +124,8 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
124
final apiService = ref.watch(serverApiServiceProvider);
125
126
if (server.type == ServerType.onDevice) {
130
- final models = OnDeviceModel.curatedModels
127
+ final models = ref
128
+ .watch(onDeviceModelsProvider)
129
.map(
130
(m) => ModelInfo(
131
id: m.id,
@@ -136,8 +134,17 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
134
parameterCount: double.tryParse(
135
m.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
136
),
137
+ fileSize: m.fileSizeBytes,
138
+ quantization: m.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
139
+ architecture: m.runtime == OnDeviceModelRuntime.llamaCpp
140
+ ? 'llama.cpp'
141
+ : null,
142
serverType: ServerType.onDevice,
143
serverId: server.id,
144
+ modifiedAt: m.importedAt,
145
+ onDeviceRuntime: m.runtime,
146
+ onDeviceFormat: m.format,
147
+ localPath: m.localPath,
148
),
149
)
150
.toList();
@@ -145,6 +152,9 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
152
return models;
153
}
154
155
+ final cached = _modelCache.get(serverId);
156
+ if (cached != null) return cached;
157
+
158
final models = await apiService.fetchModels(server);
159
_modelCache.put(serverId, models);
160
return models;
@@ -307,7 +317,6 @@ class ActiveServerNotifier extends Notifier<Server?> {
317
}
318
}
319
310
-
320
class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
321
@override
322
ConnectionStatus build() {
lib/features/settings/data/models/app_settings.dart
+8
@@ -39,6 +39,7 @@ class AppSettings {
39
final bool autoSpeakEnabled;
40
final bool smartReplyEnabled;
41
final String? localeCode;
42
+ final String? huggingFaceToken;
43
44
AppSettings({
45
this.temperature = 0.7,
@@ -69,6 +70,7 @@ class AppSettings {
70
this.autoSpeakEnabled = false,
71
this.smartReplyEnabled = true,
72
this.localeCode,
73
+ this.huggingFaceToken,
74
});
75
76
AppSettings copyWith({
@@ -100,6 +102,7 @@ class AppSettings {
102
bool? autoSpeakEnabled,
103
bool? smartReplyEnabled,
104
Object? localeCode = _unset,
105
+ Object? huggingFaceToken = _unset,
106
}) {
107
return AppSettings(
108
temperature: temperature ?? this.temperature,
@@ -138,6 +141,9 @@ class AppSettings {
141
localeCode: identical(localeCode, _unset)
142
? this.localeCode
143
: localeCode as String?,
144
+ huggingFaceToken: identical(huggingFaceToken, _unset)
145
+ ? this.huggingFaceToken
146
+ : huggingFaceToken as String?,
147
);
148
}
149
@@ -171,6 +177,7 @@ class AppSettings {
177
'autoSpeakEnabled': autoSpeakEnabled,
178
'smartReplyEnabled': smartReplyEnabled,
179
'localeCode': localeCode,
180
+ 'huggingFaceToken': huggingFaceToken,
181
};
182
}
183
@@ -204,6 +211,7 @@ class AppSettings {
211
autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
212
smartReplyEnabled: map['smartReplyEnabled'] ?? true,
213
localeCode: map['localeCode'] as String?,
214
+ huggingFaceToken: map['huggingFaceToken'] as String?,
215
);
216
}
217
lib/features/settings/views/settings_screen.dart
+141
@@ -235,6 +235,23 @@ class SettingsViews extends ConsumerWidget {
235
.read(settingsProvider.notifier)
236
.setSmartReplyEnabled(value),
237
),
238
+ _HuggingFaceTokenSetting(
239
+ currentToken: settings.huggingFaceToken,
240
+ onSave: (value) {
241
+ ref
242
+ .read(settingsProvider.notifier)
243
+ .setHuggingFaceToken(value);
244
+ ScaffoldMessenger.of(context).showSnackBar(
245
+ SnackBar(
246
+ content: Text(
247
+ value == null || value.isEmpty
248
+ ? l10n.settings_huggingface_token_cleared
249
+ : l10n.settings_huggingface_token_set,
250
+ ),
251
+ ),
252
+ );
253
+ },
254
+ ),
255
_SectionActionButton(
256
icon: Icons.phone_android_rounded,
257
label: l10n.manage_on_device_models,
@@ -1866,6 +1883,130 @@ class _InputShell extends StatelessWidget {
1883
}
1884
}
1885
1886
+class _HuggingFaceTokenSetting extends StatelessWidget {
1887
+ const _HuggingFaceTokenSetting({
1888
+ required this.currentToken,
1889
+ required this.onSave,
1890
+ });
1891
+
1892
+ final String? currentToken;
1893
+ final ValueChanged<String?> onSave;
1894
+
1895
+ bool get _hasToken => currentToken != null && currentToken!.isNotEmpty;
1896
+
1897
+ String _maskedToken(String token) {
1898
+ if (token.length <= 6) return '••••••';
1899
+ return '${token.substring(0, 4)}••••••${token.substring(token.length - 4)}';
1900
+ }
1901
+
1902
+ Future<void> _editToken(BuildContext context) async {
1903
+ final l10n = AppLocalizations.of(context)!;
1904
+ final controller = TextEditingController(text: currentToken ?? '');
1905
+ final result = await showDialog<String?>(
1906
+ context: context,
1907
+ builder: (ctx) {
1908
+ return AlertDialog(
1909
+ title: Text(l10n.edit_huggingface_token_dialog_title),
1910
+ content: TextField(
1911
+ controller: controller,
1912
+ obscureText: true,
1913
+ autofocus: true,
1914
+ decoration: InputDecoration(
1915
+ hintText: l10n.huggingface_token_dialog_hint,
1916
+ border: const OutlineInputBorder(),
1917
+ ),
1918
+ onSubmitted: (v) => Navigator.of(ctx).pop(v.trim()),
1919
+ ),
1920
+ actions: [
1921
+ TextButton(
1922
+ onPressed: () => Navigator.of(ctx).pop(),
1923
+ child: Text(l10n.cancel),
1924
+ ),
1925
+ TextButton(
1926
+ onPressed: () => Navigator.of(ctx).pop(''),
1927
+ child: Text(l10n.clear_huggingface_token),
1928
+ ),
1929
+ TextButton(
1930
+ onPressed: () =>
1931
+ Navigator.of(ctx).pop(controller.text.trim()),
1932
+ child: Text(l10n.save),
1933
+ ),
1934
+ ],
1935
+ );
1936
+ },
1937
+ );
1938
+
1939
+ controller.dispose();
1940
+ if (result == null) return;
1941
+ onSave(result.isEmpty ? null : result);
1942
+ }
1943
+
1944
+ @override
1945
+ Widget build(BuildContext context) {
1946
+ final l10n = AppLocalizations.of(context)!;
1947
+ final theme = Theme.of(context);
1948
+
1949
+ return _SettingPanel(
1950
+ child: Column(
1951
+ crossAxisAlignment: CrossAxisAlignment.start,
1952
+ children: [
1953
+ _SettingHeader(label: l10n.settings_huggingface_token),
1954
+ const SizedBox(height: 6),
1955
+ Text(
1956
+ l10n.settings_huggingface_token_desc,
1957
+ style: theme.textTheme.bodySmall?.copyWith(
1958
+ color: _mutedColor(context),
1959
+ height: 1.4,
1960
+ ),
1961
+ ),
1962
+ const SizedBox(height: 10),
1963
+ Row(
1964
+ children: [
1965
+ Expanded(
1966
+ child: _InputShell(
1967
+ child: Padding(
1968
+ padding: const EdgeInsets.symmetric(
1969
+ horizontal: 14,
1970
+ vertical: 12,
1971
+ ),
1972
+ child: Text(
1973
+ _hasToken
1974
+ ? _maskedToken(currentToken!)
1975
+ : '—',
1976
+ style: theme.textTheme.bodyMedium?.copyWith(
1977
+ fontFamily: 'monospace',
1978
+ color: _hasToken
1979
+ ? theme.colorScheme.onSurface
1980
+ : _mutedColor(context),
1981
+ ),
1982
+ ),
1983
+ ),
1984
+ ),
1985
+ ),
1986
+ const SizedBox(width: 10),
1987
+ ShadButton.outline(
1988
+ onPressed: () => _editToken(context),
1989
+ child: Text(
1990
+ _hasToken
1991
+ ? l10n.edit
1992
+ : l10n.set_huggingface_token,
1993
+ ),
1994
+ ),
1995
+ if (_hasToken) ...[
1996
+ const SizedBox(width: 6),
1997
+ ShadButton.outline(
1998
+ onPressed: () => onSave(null),
1999
+ child: Text(l10n.clear_huggingface_token),
2000
+ ),
2001
+ ],
2002
+ ],
2003
+ ),
2004
+ ],
2005
+ ),
2006
+ );
2007
+ }
2008
+}
2009
+
2010
List<Widget> _withVerticalSpacing(List<Widget> children, {double gap = 12}) {
2011
if (children.isEmpty) {
2012
return const [];
lib/l10n/app_en.arb
+53
-13
@@ -1140,26 +1140,14 @@
1140
"@server_type_on_device": {
1141
"description": "On-device server type card title"
1142
},
1143
- "server_type_on_device_sub": "NO SERVER NEEDED",
1144
- "@server_type_on_device_sub": {
1145
- "description": "On-device server type card subtitle"
1146
- },
1143
"server_type_lm_studio": "LM Studio",
1144
"@server_type_lm_studio": {
1145
"description": "LM Studio server type card title"
1146
},
1151
- "server_type_lm_studio_sub": "LOCAL API",
1152
- "@server_type_lm_studio_sub": {
1153
- "description": "LM Studio server type card subtitle"
1154
- },
1147
"server_type_ollama": "Ollama",
1148
"@server_type_ollama": {
1149
"description": "Ollama server type card title"
1150
},
1159
- "server_type_ollama_sub": "CLI ENGINE",
1160
- "@server_type_ollama_sub": {
1161
- "description": "Ollama server type card subtitle"
1162
- },
1151
"server_type_openrouter": "OpenRouter",
1152
"@server_type_openrouter": {
1153
"description": "OpenRouter server type card title"
@@ -1950,6 +1938,58 @@
1938
"@on_device_models_title": {
1939
"description": "On-device model manager screen title"
1940
},
1941
+ "settings_huggingface_token": "Hugging Face Token (Optional)",
1942
+ "@settings_huggingface_token": {
1943
+ "description": "Settings field label for an optional Hugging Face access token"
1944
+ },
1945
+ "settings_huggingface_token_desc": "Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.",
1946
+ "@settings_huggingface_token_desc": {
1947
+ "description": "Description for the Hugging Face token setting"
1948
+ },
1949
+ "settings_huggingface_token_set": "Token saved",
1950
+ "@settings_huggingface_token_set": {
1951
+ "description": "Toast text after a Hugging Face token is saved"
1952
+ },
1953
+ "settings_huggingface_token_cleared": "Token cleared",
1954
+ "@settings_huggingface_token_cleared": {
1955
+ "description": "Toast text after a Hugging Face token is cleared"
1956
+ },
1957
+ "model_requires_huggingface_token": "Requires a Hugging Face token",
1958
+ "@model_requires_huggingface_token": {
1959
+ "description": "Badge on a model that is gated on Hugging Face"
1960
+ },
1961
+ "model_missing_huggingface_token": "This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.",
1962
+ "@model_missing_huggingface_token": {
1963
+ "description": "Inline warning shown when a gated model download is attempted without a token"
1964
+ },
1965
+ "set_huggingface_token": "Set token",
1966
+ "@set_huggingface_token": {
1967
+ "description": "Button label to set/edit a Hugging Face token"
1968
+ },
1969
+ "clear_huggingface_token": "Clear",
1970
+ "@clear_huggingface_token": {
1971
+ "description": "Button label to clear a saved Hugging Face token"
1972
+ },
1973
+ "edit_huggingface_token_dialog_title": "Hugging Face Access Token",
1974
+ "@edit_huggingface_token_dialog_title": {
1975
+ "description": "Title of the dialog for entering a Hugging Face token"
1976
+ },
1977
+ "huggingface_token_dialog_hint": "hf_…",
1978
+ "@huggingface_token_dialog_hint": {
1979
+ "description": "Placeholder for the Hugging Face token input field"
1980
+ },
1981
+ "server_type_ollama_desc": "Local AI engine. No API key required.",
1982
+ "@server_type_ollama_desc": {
1983
+ "description": "Short description for the Ollama server type"
1984
+ },
1985
+ "server_type_on_device_desc": "Runs on your phone. Some models need a Hugging Face token.",
1986
+ "@server_type_on_device_desc": {
1987
+ "description": "Short description for the On-Device server type"
1988
+ },
1989
+ "server_type_lm_studio_desc": "Local API server. No API key required.",
1990
+ "@server_type_lm_studio_desc": {
1991
+ "description": "Short description for the LM Studio server type"
1992
+ },
1993
"available_models": "Available Models",
1994
"@available_models": {
1995
"description": "Available models section title"
@@ -2064,7 +2104,7 @@
2104
}
2105
}
2106
},
2067
- "model_unloaded_ollama": "{name} will be unloaded once the keep-alive time passes",
2107
+ "model_unloaded_ollama": "Unload requested for {name}. If Ollama is reachable, the model is released immediately.",
2108
"@model_unloaded_ollama": {
2109
"description": "Ollama model unloaded status",
2110
"placeholders": {
lib/l10n/app_localizations.dart
+79
-19
@@ -1629,36 +1629,18 @@ abstract class AppLocalizations {
1629
/// **'On-Device'**
1630
String get server_type_on_device;
1631
1632
- /// On-device server type card subtitle
1633
- ///
1634
- /// In en, this message translates to:
1635
- /// **'NO SERVER NEEDED'**
1636
- String get server_type_on_device_sub;
1637
-
1632
/// LM Studio server type card title
1633
///
1634
/// In en, this message translates to:
1635
/// **'LM Studio'**
1636
String get server_type_lm_studio;
1637
1644
- /// LM Studio server type card subtitle
1645
- ///
1646
- /// In en, this message translates to:
1647
- /// **'LOCAL API'**
1648
- String get server_type_lm_studio_sub;
1649
-
1638
/// Ollama server type card title
1639
///
1640
/// In en, this message translates to:
1641
/// **'Ollama'**
1642
String get server_type_ollama;
1643
1656
- /// Ollama server type card subtitle
1657
- ///
1658
- /// In en, this message translates to:
1659
- /// **'CLI ENGINE'**
1660
- String get server_type_ollama_sub;
1661
-
1644
/// OpenRouter server type card title
1645
///
1646
/// In en, this message translates to:
@@ -2691,6 +2673,84 @@ abstract class AppLocalizations {
2673
/// **'On-Device Models'**
2674
String get on_device_models_title;
2675
2676
+ /// Settings field label for an optional Hugging Face access token
2677
+ ///
2678
+ /// In en, this message translates to:
2679
+ /// **'Hugging Face Token (Optional)'**
2680
+ String get settings_huggingface_token;
2681
+
2682
+ /// Description for the Hugging Face token setting
2683
+ ///
2684
+ /// In en, this message translates to:
2685
+ /// **'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.'**
2686
+ String get settings_huggingface_token_desc;
2687
+
2688
+ /// Toast text after a Hugging Face token is saved
2689
+ ///
2690
+ /// In en, this message translates to:
2691
+ /// **'Token saved'**
2692
+ String get settings_huggingface_token_set;
2693
+
2694
+ /// Toast text after a Hugging Face token is cleared
2695
+ ///
2696
+ /// In en, this message translates to:
2697
+ /// **'Token cleared'**
2698
+ String get settings_huggingface_token_cleared;
2699
+
2700
+ /// Badge on a model that is gated on Hugging Face
2701
+ ///
2702
+ /// In en, this message translates to:
2703
+ /// **'Requires a Hugging Face token'**
2704
+ String get model_requires_huggingface_token;
2705
+
2706
+ /// Inline warning shown when a gated model download is attempted without a token
2707
+ ///
2708
+ /// In en, this message translates to:
2709
+ /// **'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.'**
2710
+ String get model_missing_huggingface_token;
2711
+
2712
+ /// Button label to set/edit a Hugging Face token
2713
+ ///
2714
+ /// In en, this message translates to:
2715
+ /// **'Set token'**
2716
+ String get set_huggingface_token;
2717
+
2718
+ /// Button label to clear a saved Hugging Face token
2719
+ ///
2720
+ /// In en, this message translates to:
2721
+ /// **'Clear'**
2722
+ String get clear_huggingface_token;
2723
+
2724
+ /// Title of the dialog for entering a Hugging Face token
2725
+ ///
2726
+ /// In en, this message translates to:
2727
+ /// **'Hugging Face Access Token'**
2728
+ String get edit_huggingface_token_dialog_title;
2729
+
2730
+ /// Placeholder for the Hugging Face token input field
2731
+ ///
2732
+ /// In en, this message translates to:
2733
+ /// **'hf_…'**
2734
+ String get huggingface_token_dialog_hint;
2735
+
2736
+ /// Short description for the Ollama server type
2737
+ ///
2738
+ /// In en, this message translates to:
2739
+ /// **'Local AI engine. No API key required.'**
2740
+ String get server_type_ollama_desc;
2741
+
2742
+ /// Short description for the On-Device server type
2743
+ ///
2744
+ /// In en, this message translates to:
2745
+ /// **'Runs on your phone. Some models need a Hugging Face token.'**
2746
+ String get server_type_on_device_desc;
2747
+
2748
+ /// Short description for the LM Studio server type
2749
+ ///
2750
+ /// In en, this message translates to:
2751
+ /// **'Local API server. No API key required.'**
2752
+ String get server_type_lm_studio_desc;
2753
+
2754
/// Available models section title
2755
///
2756
/// In en, this message translates to:
@@ -2838,7 +2898,7 @@ abstract class AppLocalizations {
2898
/// Ollama model unloaded status
2899
///
2900
/// In en, this message translates to:
2841
- /// **'{name} will be unloaded once the keep-alive time passes'**
2901
+ /// **'Unload requested for {name}. If Ollama is reachable, the model is released immediately.'**
2902
String model_unloaded_ollama(String name);
2903
2904
/// Model unloaded success snackbar
lib/l10n/app_localizations_ar.dart
+44
-9
@@ -815,21 +815,12 @@ class AppLocalizationsAr extends AppLocalizations {
815
@override
816
String get server_type_on_device => 'محلي';
817
818
- @override
819
- String get server_type_on_device_sub => 'لا يحتاج خادماً';
820
-
818
@override
819
String get server_type_lm_studio => 'LM Studio';
820
824
- @override
825
- String get server_type_lm_studio_sub => 'API محلي';
826
-
821
@override
822
String get server_type_ollama => 'Ollama';
823
830
- @override
831
- String get server_type_ollama_sub => 'محرك أوامر';
832
-
824
@override
825
String get server_type_openrouter => 'OpenRouter';
826
@@ -1379,6 +1370,50 @@ class AppLocalizationsAr extends AppLocalizations {
1370
@override
1371
String get on_device_models_title => 'النماذج المحلية';
1372
1373
+ @override
1374
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1375
+
1376
+ @override
1377
+ String get settings_huggingface_token_desc =>
1378
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1379
+
1380
+ @override
1381
+ String get settings_huggingface_token_set => 'Token saved';
1382
+
1383
+ @override
1384
+ String get settings_huggingface_token_cleared => 'Token cleared';
1385
+
1386
+ @override
1387
+ String get model_requires_huggingface_token =>
1388
+ 'Requires a Hugging Face token';
1389
+
1390
+ @override
1391
+ String get model_missing_huggingface_token =>
1392
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1393
+
1394
+ @override
1395
+ String get set_huggingface_token => 'Set token';
1396
+
1397
+ @override
1398
+ String get clear_huggingface_token => 'Clear';
1399
+
1400
+ @override
1401
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1402
+
1403
+ @override
1404
+ String get huggingface_token_dialog_hint => 'hf_…';
1405
+
1406
+ @override
1407
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1408
+
1409
+ @override
1410
+ String get server_type_on_device_desc =>
1411
+ 'Runs on your phone. Some models need a Hugging Face token.';
1412
+
1413
+ @override
1414
+ String get server_type_lm_studio_desc =>
1415
+ 'Local API server. No API key required.';
1416
+
1417
@override
1418
String get available_models => 'النماذج المتاحة';
1419
lib/l10n/app_localizations_bn.dart
+44
-9
@@ -819,21 +819,12 @@ class AppLocalizationsBn extends AppLocalizations {
819
@override
820
String get server_type_on_device => 'অন-ডিভাইস (On-Device)';
821
822
- @override
823
- String get server_type_on_device_sub => 'কোনো সার্ভারের প্রয়োজন নেই';
824
-
822
@override
823
String get server_type_lm_studio => 'LM Studio';
824
828
- @override
829
- String get server_type_lm_studio_sub => 'লোকাল এপিআই';
830
-
825
@override
826
String get server_type_ollama => 'Ollama';
827
834
- @override
835
- String get server_type_ollama_sub => 'সিএলআই ইঞ্জিন';
836
-
828
@override
829
String get server_type_openrouter => 'OpenRouter';
830
@@ -1385,6 +1376,50 @@ class AppLocalizationsBn extends AppLocalizations {
1376
@override
1377
String get on_device_models_title => 'অন-ডিভাইস মডেলসমূহ';
1378
1379
+ @override
1380
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1381
+
1382
+ @override
1383
+ String get settings_huggingface_token_desc =>
1384
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1385
+
1386
+ @override
1387
+ String get settings_huggingface_token_set => 'Token saved';
1388
+
1389
+ @override
1390
+ String get settings_huggingface_token_cleared => 'Token cleared';
1391
+
1392
+ @override
1393
+ String get model_requires_huggingface_token =>
1394
+ 'Requires a Hugging Face token';
1395
+
1396
+ @override
1397
+ String get model_missing_huggingface_token =>
1398
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1399
+
1400
+ @override
1401
+ String get set_huggingface_token => 'Set token';
1402
+
1403
+ @override
1404
+ String get clear_huggingface_token => 'Clear';
1405
+
1406
+ @override
1407
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1408
+
1409
+ @override
1410
+ String get huggingface_token_dialog_hint => 'hf_…';
1411
+
1412
+ @override
1413
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1414
+
1415
+ @override
1416
+ String get server_type_on_device_desc =>
1417
+ 'Runs on your phone. Some models need a Hugging Face token.';
1418
+
1419
+ @override
1420
+ String get server_type_lm_studio_desc =>
1421
+ 'Local API server. No API key required.';
1422
+
1423
@override
1424
String get available_models => 'উপলব্ধ মডেলসমূহ';
1425
lib/l10n/app_localizations_en.dart
+45
-10
@@ -815,21 +815,12 @@ class AppLocalizationsEn extends AppLocalizations {
815
@override
816
String get server_type_on_device => 'On-Device';
817
818
- @override
819
- String get server_type_on_device_sub => 'NO SERVER NEEDED';
820
-
818
@override
819
String get server_type_lm_studio => 'LM Studio';
820
824
- @override
825
- String get server_type_lm_studio_sub => 'LOCAL API';
826
-
821
@override
822
String get server_type_ollama => 'Ollama';
823
830
- @override
831
- String get server_type_ollama_sub => 'CLI ENGINE';
832
-
824
@override
825
String get server_type_openrouter => 'OpenRouter';
826
@@ -1379,6 +1370,50 @@ class AppLocalizationsEn extends AppLocalizations {
1370
@override
1371
String get on_device_models_title => 'On-Device Models';
1372
1373
+ @override
1374
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1375
+
1376
+ @override
1377
+ String get settings_huggingface_token_desc =>
1378
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1379
+
1380
+ @override
1381
+ String get settings_huggingface_token_set => 'Token saved';
1382
+
1383
+ @override
1384
+ String get settings_huggingface_token_cleared => 'Token cleared';
1385
+
1386
+ @override
1387
+ String get model_requires_huggingface_token =>
1388
+ 'Requires a Hugging Face token';
1389
+
1390
+ @override
1391
+ String get model_missing_huggingface_token =>
1392
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1393
+
1394
+ @override
1395
+ String get set_huggingface_token => 'Set token';
1396
+
1397
+ @override
1398
+ String get clear_huggingface_token => 'Clear';
1399
+
1400
+ @override
1401
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1402
+
1403
+ @override
1404
+ String get huggingface_token_dialog_hint => 'hf_…';
1405
+
1406
+ @override
1407
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1408
+
1409
+ @override
1410
+ String get server_type_on_device_desc =>
1411
+ 'Runs on your phone. Some models need a Hugging Face token.';
1412
+
1413
+ @override
1414
+ String get server_type_lm_studio_desc =>
1415
+ 'Local API server. No API key required.';
1416
+
1417
@override
1418
String get available_models => 'Available Models';
1419
@@ -1461,7 +1496,7 @@ class AppLocalizationsEn extends AppLocalizations {
1496
1497
@override
1498
String model_unloaded_ollama(String name) {
1464
- return '$name will be unloaded once the keep-alive time passes';
1499
+ return 'Unload requested for $name. If Ollama is reachable, the model is released immediately.';
1500
}
1501
1502
@override
lib/l10n/app_localizations_es.dart
+44
-9
@@ -819,21 +819,12 @@ class AppLocalizationsEs extends AppLocalizations {
819
@override
820
String get server_type_on_device => 'Local';
821
822
- @override
823
- String get server_type_on_device_sub => 'NO SE REQUIERE SERVIDOR';
824
-
822
@override
823
String get server_type_lm_studio => 'LM Studio';
824
828
- @override
829
- String get server_type_lm_studio_sub => 'API LOCAL';
830
-
825
@override
826
String get server_type_ollama => 'Ollama';
827
834
- @override
835
- String get server_type_ollama_sub => 'MOTOR CLI';
836
-
828
@override
829
String get server_type_openrouter => 'OpenRouter';
830
@@ -1389,6 +1380,50 @@ class AppLocalizationsEs extends AppLocalizations {
1380
@override
1381
String get on_device_models_title => 'Modelos locales';
1382
1383
+ @override
1384
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1385
+
1386
+ @override
1387
+ String get settings_huggingface_token_desc =>
1388
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1389
+
1390
+ @override
1391
+ String get settings_huggingface_token_set => 'Token saved';
1392
+
1393
+ @override
1394
+ String get settings_huggingface_token_cleared => 'Token cleared';
1395
+
1396
+ @override
1397
+ String get model_requires_huggingface_token =>
1398
+ 'Requires a Hugging Face token';
1399
+
1400
+ @override
1401
+ String get model_missing_huggingface_token =>
1402
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1403
+
1404
+ @override
1405
+ String get set_huggingface_token => 'Set token';
1406
+
1407
+ @override
1408
+ String get clear_huggingface_token => 'Clear';
1409
+
1410
+ @override
1411
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1412
+
1413
+ @override
1414
+ String get huggingface_token_dialog_hint => 'hf_…';
1415
+
1416
+ @override
1417
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1418
+
1419
+ @override
1420
+ String get server_type_on_device_desc =>
1421
+ 'Runs on your phone. Some models need a Hugging Face token.';
1422
+
1423
+ @override
1424
+ String get server_type_lm_studio_desc =>
1425
+ 'Local API server. No API key required.';
1426
+
1427
@override
1428
String get available_models => 'Modelos disponibles';
1429
lib/l10n/app_localizations_hi.dart
+44
-9
@@ -818,21 +818,12 @@ class AppLocalizationsHi extends AppLocalizations {
818
@override
819
String get server_type_on_device => 'ऑन-डिवाइस';
820
821
- @override
822
- String get server_type_on_device_sub => 'किसी सर्वर की आवश्यकता नहीं है';
823
-
821
@override
822
String get server_type_lm_studio => 'LM Studio';
823
827
- @override
828
- String get server_type_lm_studio_sub => 'स्थानीय API';
829
-
824
@override
825
String get server_type_ollama => 'Ollama';
826
833
- @override
834
- String get server_type_ollama_sub => 'CLI इंजन';
835
-
827
@override
828
String get server_type_openrouter => 'OpenRouter';
829
@@ -1383,6 +1374,50 @@ class AppLocalizationsHi extends AppLocalizations {
1374
@override
1375
String get on_device_models_title => 'ऑन-डिवाइस मॉडल';
1376
1377
+ @override
1378
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1379
+
1380
+ @override
1381
+ String get settings_huggingface_token_desc =>
1382
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1383
+
1384
+ @override
1385
+ String get settings_huggingface_token_set => 'Token saved';
1386
+
1387
+ @override
1388
+ String get settings_huggingface_token_cleared => 'Token cleared';
1389
+
1390
+ @override
1391
+ String get model_requires_huggingface_token =>
1392
+ 'Requires a Hugging Face token';
1393
+
1394
+ @override
1395
+ String get model_missing_huggingface_token =>
1396
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1397
+
1398
+ @override
1399
+ String get set_huggingface_token => 'Set token';
1400
+
1401
+ @override
1402
+ String get clear_huggingface_token => 'Clear';
1403
+
1404
+ @override
1405
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1406
+
1407
+ @override
1408
+ String get huggingface_token_dialog_hint => 'hf_…';
1409
+
1410
+ @override
1411
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1412
+
1413
+ @override
1414
+ String get server_type_on_device_desc =>
1415
+ 'Runs on your phone. Some models need a Hugging Face token.';
1416
+
1417
+ @override
1418
+ String get server_type_lm_studio_desc =>
1419
+ 'Local API server. No API key required.';
1420
+
1421
@override
1422
String get available_models => 'उपलब्ध मॉडल';
1423
lib/l10n/app_localizations_it.dart
+44
-9
@@ -819,21 +819,12 @@ class AppLocalizationsIt extends AppLocalizations {
819
@override
820
String get server_type_on_device => 'On-Device';
821
822
- @override
823
- String get server_type_on_device_sub => 'NESSUN SERVER NECESSARIO';
824
-
822
@override
823
String get server_type_lm_studio => 'LM Studio';
824
828
- @override
829
- String get server_type_lm_studio_sub => 'API LOCALE';
830
-
825
@override
826
String get server_type_ollama => 'Ollama';
827
834
- @override
835
- String get server_type_ollama_sub => 'MOTORE CLI';
836
-
828
@override
829
String get server_type_openrouter => 'OpenRouter';
830
@@ -1388,6 +1379,50 @@ class AppLocalizationsIt extends AppLocalizations {
1379
@override
1380
String get on_device_models_title => 'Modelli sul dispositivo';
1381
1382
+ @override
1383
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1384
+
1385
+ @override
1386
+ String get settings_huggingface_token_desc =>
1387
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1388
+
1389
+ @override
1390
+ String get settings_huggingface_token_set => 'Token saved';
1391
+
1392
+ @override
1393
+ String get settings_huggingface_token_cleared => 'Token cleared';
1394
+
1395
+ @override
1396
+ String get model_requires_huggingface_token =>
1397
+ 'Requires a Hugging Face token';
1398
+
1399
+ @override
1400
+ String get model_missing_huggingface_token =>
1401
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1402
+
1403
+ @override
1404
+ String get set_huggingface_token => 'Set token';
1405
+
1406
+ @override
1407
+ String get clear_huggingface_token => 'Clear';
1408
+
1409
+ @override
1410
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1411
+
1412
+ @override
1413
+ String get huggingface_token_dialog_hint => 'hf_…';
1414
+
1415
+ @override
1416
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1417
+
1418
+ @override
1419
+ String get server_type_on_device_desc =>
1420
+ 'Runs on your phone. Some models need a Hugging Face token.';
1421
+
1422
+ @override
1423
+ String get server_type_lm_studio_desc =>
1424
+ 'Local API server. No API key required.';
1425
+
1426
@override
1427
String get available_models => 'Modelli disponibili';
1428
lib/l10n/app_localizations_ja.dart
+44
-9
@@ -807,21 +807,12 @@ class AppLocalizationsJa extends AppLocalizations {
807
@override
808
String get server_type_on_device => 'ローカル';
809
810
- @override
811
- String get server_type_on_device_sub => 'サーバー不要';
812
-
810
@override
811
String get server_type_lm_studio => 'LM Studio';
812
816
- @override
817
- String get server_type_lm_studio_sub => 'ローカルAPI';
818
-
813
@override
814
String get server_type_ollama => 'Ollama';
815
822
- @override
823
- String get server_type_ollama_sub => 'CLIエンジン';
824
-
816
@override
817
String get server_type_openrouter => 'OpenRouter';
818
@@ -1366,6 +1357,50 @@ class AppLocalizationsJa extends AppLocalizations {
1357
@override
1358
String get on_device_models_title => 'ローカルモデル';
1359
1360
+ @override
1361
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1362
+
1363
+ @override
1364
+ String get settings_huggingface_token_desc =>
1365
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1366
+
1367
+ @override
1368
+ String get settings_huggingface_token_set => 'Token saved';
1369
+
1370
+ @override
1371
+ String get settings_huggingface_token_cleared => 'Token cleared';
1372
+
1373
+ @override
1374
+ String get model_requires_huggingface_token =>
1375
+ 'Requires a Hugging Face token';
1376
+
1377
+ @override
1378
+ String get model_missing_huggingface_token =>
1379
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1380
+
1381
+ @override
1382
+ String get set_huggingface_token => 'Set token';
1383
+
1384
+ @override
1385
+ String get clear_huggingface_token => 'Clear';
1386
+
1387
+ @override
1388
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1389
+
1390
+ @override
1391
+ String get huggingface_token_dialog_hint => 'hf_…';
1392
+
1393
+ @override
1394
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1395
+
1396
+ @override
1397
+ String get server_type_on_device_desc =>
1398
+ 'Runs on your phone. Some models need a Hugging Face token.';
1399
+
1400
+ @override
1401
+ String get server_type_lm_studio_desc =>
1402
+ 'Local API server. No API key required.';
1403
+
1404
@override
1405
String get available_models => '利用可能なモデル';
1406
lib/l10n/app_localizations_zh.dart
+44
-9
@@ -804,21 +804,12 @@ class AppLocalizationsZh extends AppLocalizations {
804
@override
805
String get server_type_on_device => '设备端';
806
807
- @override
808
- String get server_type_on_device_sub => '无需服务器';
809
-
807
@override
808
String get server_type_lm_studio => 'LM Studio';
809
813
- @override
814
- String get server_type_lm_studio_sub => '本地 API';
815
-
810
@override
811
String get server_type_ollama => 'Ollama';
812
819
- @override
820
- String get server_type_ollama_sub => 'CLI 引擎';
821
-
813
@override
814
String get server_type_openrouter => 'OpenRouter';
815
@@ -1361,6 +1352,50 @@ class AppLocalizationsZh extends AppLocalizations {
1352
@override
1353
String get on_device_models_title => '设备端模型';
1354
1355
+ @override
1356
+ String get settings_huggingface_token => 'Hugging Face Token (Optional)';
1357
+
1358
+ @override
1359
+ String get settings_huggingface_token_desc =>
1360
+ 'Required only for gated models (e.g. Gemma). Get a token at huggingface.co/settings/tokens.';
1361
+
1362
+ @override
1363
+ String get settings_huggingface_token_set => 'Token saved';
1364
+
1365
+ @override
1366
+ String get settings_huggingface_token_cleared => 'Token cleared';
1367
+
1368
+ @override
1369
+ String get model_requires_huggingface_token =>
1370
+ 'Requires a Hugging Face token';
1371
+
1372
+ @override
1373
+ String get model_missing_huggingface_token =>
1374
+ 'This model is gated on Hugging Face. Add a token in Settings → On-Device Inference to download it.';
1375
+
1376
+ @override
1377
+ String get set_huggingface_token => 'Set token';
1378
+
1379
+ @override
1380
+ String get clear_huggingface_token => 'Clear';
1381
+
1382
+ @override
1383
+ String get edit_huggingface_token_dialog_title => 'Hugging Face Access Token';
1384
+
1385
+ @override
1386
+ String get huggingface_token_dialog_hint => 'hf_…';
1387
+
1388
+ @override
1389
+ String get server_type_ollama_desc => 'Local AI engine. No API key required.';
1390
+
1391
+ @override
1392
+ String get server_type_on_device_desc =>
1393
+ 'Runs on your phone. Some models need a Hugging Face token.';
1394
+
1395
+ @override
1396
+ String get server_type_lm_studio_desc =>
1397
+ 'Local API server. No API key required.';
1398
+
1399
@override
1400
String get available_models => '可用模型';
1401
pubspec.lock
+24
@@ -353,6 +353,14 @@ packages:
353
url: "https://pub.dev"
354
source: hosted
355
version: "8.1.0"
356
+ dinja:
357
+ dependency: transitive
358
+ description:
359
+ name: dinja
360
+ sha256: "34d4e569ceb3d900ab061f16cae947223968cc63d2484a8a4710d5ecfa4ae3a2"
361
+ url: "https://pub.dev"
362
+ source: hosted
363
+ version: "1.0.0"
364
dio:
365
dependency: "direct main"
366
description:
@@ -877,6 +885,22 @@ packages:
885
url: "https://pub.dev"
886
source: hosted
887
version: "6.1.0"
888
+ llamadart:
889
+ dependency: "direct main"
890
+ description:
891
+ name: llamadart
892
+ sha256: "814bbfc71e3629163372a62421e00b2ba695987fcd6ac811a075fe0fed00e524"
893
+ url: "https://pub.dev"
894
+ source: hosted
895
+ version: "0.8.4"
896
+ llamadart_llama_cpp_flutter:
897
+ dependency: "direct main"
898
+ description:
899
+ name: llamadart_llama_cpp_flutter
900
+ sha256: "42b61754139aeef18cf571e12ce4194c56f5f3ead9dab5b253ce69136c4976d5"
901
+ url: "https://pub.dev"
902
+ source: hosted
903
+ version: "0.0.4"
904
loading_animation_widget:
905
dependency: "direct main"
906
description:
pubspec.yaml
+7
@@ -62,6 +62,8 @@ dependencies:
62
path: ^1.9.1
63
flutter_gemma: ^1.0.0
64
flutter_gemma_litertlm: ^1.0.0
65
+ llamadart: ^0.8.4
66
+ llamadart_llama_cpp_flutter: ^0.0.4
67
wakelock_plus: ^1.2.8
68
flutter_local_notifications: ^22.0.1
69
audioplayers: ^6.4.0
@@ -132,3 +134,8 @@ flutter:
134
#
135
# For details regarding fonts from package dependencies,
136
# see https://flutter.dev/to/font-from-package
137
+
138
+hooks:
139
+ user_defines:
140
+ llamadart:
141
+ llamadart_native_runtimes: [llama_cpp]
test/features/chat/chat_service_routing_test.dart
new
+105
@@ -0,0 +1,105 @@
1
+import 'package:dio/dio.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/core/models/enums.dart';
5
+import 'package:localmind/core/providers/storage_providers.dart';
6
+import 'package:localmind/features/chat/providers/chat_providers.dart';
7
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
8
+import 'package:localmind/features/on_device/data/on_device_chat_service.dart';
9
+import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
10
+import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
11
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
12
+import 'package:localmind/features/servers/data/models/server.dart';
13
+import 'package:localmind/features/servers/providers/server_providers.dart';
14
+import 'package:shared_preferences/shared_preferences.dart';
15
+
16
+void main() {
17
+ group('createChatServiceForServer', () {
18
+ test('routes imported GGUF models to llama.cpp service', () {
19
+ final service = createChatServiceForServer(
20
+ server: _onDeviceServer(),
21
+ dio: Dio(),
22
+ onDeviceGemmaService: OnDeviceGemmaService(),
23
+ onDeviceLlamaService: OnDeviceLlamaService(),
24
+ loadedOnDeviceRuntime: OnDeviceModelRuntime.llamaCpp,
25
+ );
26
+
27
+ expect(service, isA<OnDeviceLlamaChatService>());
28
+ });
29
+
30
+ test('routes curated on-device models to Gemma service', () {
31
+ final service = createChatServiceForServer(
32
+ server: _onDeviceServer(),
33
+ dio: Dio(),
34
+ onDeviceGemmaService: OnDeviceGemmaService(),
35
+ onDeviceLlamaService: OnDeviceLlamaService(),
36
+ loadedOnDeviceRuntime: OnDeviceModelRuntime.gemma,
37
+ );
38
+
39
+ expect(service, isA<OnDeviceChatService>());
40
+ });
41
+ });
42
+
43
+ group('chatServiceProvider', () {
44
+ test('only recreates when loadedRuntime changes, not on every status change',
45
+ () async {
46
+ SharedPreferences.setMockInitialValues({});
47
+ final prefs = await SharedPreferences.getInstance();
48
+ final container = ProviderContainer(
49
+ overrides: [
50
+ sharedPreferencesProvider.overrideWithValue(prefs),
51
+ activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
52
+ onDeviceGemmaServiceProvider.overrideWithValue(
53
+ _FakeOnDeviceGemmaService(),
54
+ ),
55
+ ],
56
+ );
57
+ addTearDown(container.dispose);
58
+
59
+ final initialService = container.read(chatServiceProvider);
60
+ expect(initialService, isA<OnDeviceChatService>());
61
+
62
+ // Toggle status without changing runtime — the service instance must be
63
+ // preserved so we don't drop an in-flight chat.
64
+ container.read(onDeviceEngineProvider.notifier).state =
65
+ const OnDeviceEngineState(
66
+ status: OnDeviceEngineStatus.loading,
67
+ );
68
+ final stillSameService = container.read(chatServiceProvider);
69
+ expect(identical(initialService, stillSameService), isTrue);
70
+
71
+ // Switching the runtime rebuilds the service so it can pick up the
72
+ // llama.cpp adapter.
73
+ container.read(onDeviceEngineProvider.notifier).state =
74
+ const OnDeviceEngineState(
75
+ status: OnDeviceEngineStatus.loaded,
76
+ loadedRuntime: OnDeviceModelRuntime.llamaCpp,
77
+ );
78
+ final llamaService = container.read(chatServiceProvider);
79
+ expect(llamaService, isA<OnDeviceLlamaChatService>());
80
+ });
81
+ });
82
+}
83
+
84
+class _StubActiveServerNotifier extends ActiveServerNotifier {
85
+ @override
86
+ Server? build() => _onDeviceServer();
87
+}
88
+
89
+class _FakeOnDeviceGemmaService extends OnDeviceGemmaService {
90
+ @override
91
+ Future<List<String>> getInstalledModelIds() async => const [];
92
+}
93
+
94
+Server _onDeviceServer() {
95
+ return Server(
96
+ id: 'on-device',
97
+ name: 'On-Device',
98
+ type: ServerType.onDevice,
99
+ host: '',
100
+ port: 0,
101
+ createdAt: DateTime.utc(2026, 6, 21),
102
+ lastConnectedAt: DateTime.utc(2026, 6, 21),
103
+ status: ConnectionStatus.connected,
104
+ );
105
+}
test/features/on_device/imported_gguf_model_repository_test.dart
new
+134
@@ -0,0 +1,134 @@
1
+import 'dart:convert';
2
+import 'dart:io';
3
+
4
+import 'package:flutter_test/flutter_test.dart';
5
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
6
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
7
+import 'package:shared_preferences/shared_preferences.dart';
8
+
9
+void main() {
10
+ const storageKey = 'imported_gguf_models_v1';
11
+
12
+ Future<ImportedGgufModelRepository> createRepository({
13
+ Map<String, Object> initialValues = const {},
14
+ }) async {
15
+ SharedPreferences.setMockInitialValues(initialValues);
16
+ final prefs = await SharedPreferences.getInstance();
17
+ return ImportedGgufModelRepository(prefs);
18
+ }
19
+
20
+ group('ImportedGgufModelMetadata', () {
21
+ test('serializes and converts to a llama.cpp on-device model', () {
22
+ final importedAt = DateTime.utc(2026, 6, 21, 12);
23
+ final metadata = ImportedGgufModelMetadata(
24
+ id: 'gguf-test',
25
+ name: 'Tiny Llama',
26
+ filePath: '/tmp/Tiny-Llama.Q4.gguf',
27
+ fileSizeBytes: 1234,
28
+ importedAt: importedAt,
29
+ );
30
+
31
+ final restored = ImportedGgufModelMetadata.fromJson(
32
+ json.decode(json.encode(metadata.toJson())) as Map<String, dynamic>,
33
+ );
34
+ final model = restored.toOnDeviceModel();
35
+
36
+ expect(restored.id, metadata.id);
37
+ expect(restored.name, metadata.name);
38
+ expect(restored.filePath, metadata.filePath);
39
+ expect(restored.fileSizeBytes, metadata.fileSizeBytes);
40
+ expect(restored.importedAt, metadata.importedAt);
41
+ expect(model.runtime, OnDeviceModelRuntime.llamaCpp);
42
+ expect(model.format, OnDeviceModelFormat.gguf);
43
+ expect(model.localPath, metadata.filePath);
44
+ expect(model.fileName, 'Tiny-Llama.Q4.gguf');
45
+ expect(model.isImported, isTrue);
46
+ expect(model.isLlamaCpp, isTrue);
47
+ });
48
+ });
49
+
50
+ group('ImportedGgufModelRepository', () {
51
+ late Directory tempDir;
52
+
53
+ setUp(() async {
54
+ tempDir = await Directory.systemTemp.createTemp('localmind_gguf_test_');
55
+ });
56
+
57
+ tearDown(() async {
58
+ if (await tempDir.exists()) {
59
+ await tempDir.delete(recursive: true);
60
+ }
61
+ });
62
+
63
+ test('loads saved metadata from versioned SharedPreferences key', () async {
64
+ final metadata = _metadata(
65
+ id: 'gguf-saved',
66
+ filePath: '${tempDir.path}/saved.gguf',
67
+ );
68
+ final repository = await createRepository(
69
+ initialValues: {
70
+ storageKey: json.encode([metadata.toJson()]),
71
+ },
72
+ );
73
+
74
+ final models = repository.load();
75
+
76
+ expect(models, hasLength(1));
77
+ expect(models.single.id, 'gguf-saved');
78
+ expect(models.single.filePath, metadata.filePath);
79
+ });
80
+
81
+ test('delete removes metadata and the copied model file', () async {
82
+ final file = File('${tempDir.path}/delete-me.gguf');
83
+ await file.writeAsString('gguf bytes');
84
+ final repository = await createRepository();
85
+ await repository.saveAll([
86
+ _metadata(id: 'gguf-delete-me', filePath: file.path),
87
+ ]);
88
+
89
+ await repository.delete('gguf-delete-me');
90
+
91
+ expect(await file.exists(), isFalse);
92
+ expect(repository.load(), isEmpty);
93
+ });
94
+
95
+ test('delete handles missing files gracefully', () async {
96
+ final repository = await createRepository();
97
+ await repository.saveAll([
98
+ _metadata(id: 'gguf-missing', filePath: '${tempDir.path}/missing.gguf'),
99
+ ]);
100
+
101
+ await repository.delete('gguf-missing');
102
+
103
+ expect(repository.load(), isEmpty);
104
+ });
105
+
106
+ test('loadExisting prunes stale metadata for missing files', () async {
107
+ final file = File('${tempDir.path}/kept.gguf');
108
+ await file.writeAsString('gguf bytes');
109
+ final repository = await createRepository();
110
+ await repository.saveAll([
111
+ _metadata(id: 'gguf-kept', filePath: file.path),
112
+ _metadata(id: 'gguf-stale', filePath: '${tempDir.path}/stale.gguf'),
113
+ ]);
114
+
115
+ final existing = await repository.loadExisting();
116
+
117
+ expect(existing.map((model) => model.id), ['gguf-kept']);
118
+ expect(repository.load().map((model) => model.id), ['gguf-kept']);
119
+ });
120
+ });
121
+}
122
+
123
+ImportedGgufModelMetadata _metadata({
124
+ required String id,
125
+ required String filePath,
126
+}) {
127
+ return ImportedGgufModelMetadata(
128
+ id: id,
129
+ name: 'Test GGUF',
130
+ filePath: filePath,
131
+ fileSizeBytes: 42,
132
+ importedAt: DateTime.utc(2026, 6, 21),
133
+ );
134
+}
test/features/on_device/model_manager_gguf_widget_test.dart
new
+91
@@ -0,0 +1,91 @@
1
+import 'dart:convert';
2
+
3
+import 'package:flutter/foundation.dart';
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:flutter_test/flutter_test.dart';
7
+import 'package:localmind/core/device/device_memory_service.dart';
8
+import 'package:localmind/core/providers/device_info_providers.dart';
9
+import 'package:localmind/core/providers/storage_providers.dart';
10
+import 'package:localmind/core/theme/app_theme.dart';
11
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
12
+import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
13
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
14
+import 'package:localmind/features/on_device/views/model_manager_screen.dart';
15
+import 'package:localmind/l10n/app_localizations.dart';
16
+import 'package:shared_preferences/shared_preferences.dart';
17
+import 'package:shadcn_ui/shadcn_ui.dart';
18
+
19
+void main() {
20
+ const storageKey = 'imported_gguf_models_v1';
21
+
22
+ testWidgets('model manager shows Import GGUF and imported model actions', (
23
+ tester,
24
+ ) async {
25
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
26
+ try {
27
+ final metadata = ImportedGgufModelMetadata(
28
+ id: 'gguf-widget',
29
+ name: 'Widget GGUF',
30
+ filePath: '/tmp/widget.gguf',
31
+ fileSizeBytes: 4096,
32
+ importedAt: DateTime.utc(2026, 6, 21),
33
+ );
34
+ SharedPreferences.setMockInitialValues({
35
+ storageKey: json.encode([metadata.toJson()]),
36
+ });
37
+ final prefs = await SharedPreferences.getInstance();
38
+
39
+ await tester.pumpWidget(
40
+ ProviderScope(
41
+ overrides: [
42
+ sharedPreferencesProvider.overrideWithValue(prefs),
43
+ onDeviceGemmaServiceProvider.overrideWithValue(
44
+ _FakeOnDeviceGemmaService(),
45
+ ),
46
+ deviceMemoryProvider.overrideWith(
47
+ (ref) async => const DeviceMemoryInfo(
48
+ totalMemoryMb: 8192,
49
+ availableMemoryMb: 8192,
50
+ ),
51
+ ),
52
+ ],
53
+ child: ShadTheme(
54
+ data: AppTheme.lightShadTheme,
55
+ child: MaterialApp(
56
+ locale: const Locale('en'),
57
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
58
+ supportedLocales: AppLocalizations.supportedLocales,
59
+ home: const Scaffold(body: OnDeviceModelManagerScreen()),
60
+ ),
61
+ ),
62
+ ),
63
+ );
64
+ await tester.pumpAndSettle();
65
+
66
+ expect(find.text('Import GGUF'), findsOneWidget);
67
+ await tester.scrollUntilVisible(
68
+ find.text('Widget GGUF'),
69
+ 500,
70
+ scrollable: find.byType(Scrollable).last,
71
+ );
72
+ await tester.pumpAndSettle();
73
+
74
+ expect(find.text('Widget GGUF'), findsOneWidget);
75
+ expect(find.text('Imported'), findsWidgets);
76
+ expect(find.text('GGUF'), findsWidgets);
77
+ expect(find.text('llama.cpp'), findsWidgets);
78
+ expect(find.text('Load'), findsWidgets);
79
+ expect(find.text('Delete'), findsWidgets);
80
+ } finally {
81
+ debugDefaultTargetPlatformOverride = null;
82
+ }
83
+ });
84
+}
85
+
86
+class _FakeOnDeviceGemmaService extends OnDeviceGemmaService {
87
+ @override
88
+ Future<List<String>> getInstalledModelIds() async {
89
+ return [];
90
+ }
91
+}
test/features/on_device/on_device_imported_models_provider_test.dart
new
+81
@@ -0,0 +1,81 @@
1
+import 'dart:convert';
2
+
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:flutter_test/flutter_test.dart';
5
+import 'package:localmind/core/providers/storage_providers.dart';
6
+import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
7
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
8
+import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
9
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
10
+import 'package:shared_preferences/shared_preferences.dart';
11
+
12
+void main() {
13
+ const storageKey = 'imported_gguf_models_v1';
14
+
15
+ test(
16
+ 'onDeviceModelsProvider merges curated and imported GGUF models',
17
+ () async {
18
+ final metadata = ImportedGgufModelMetadata(
19
+ id: 'gguf-imported',
20
+ name: 'Imported GGUF',
21
+ filePath: '/tmp/imported.gguf',
22
+ fileSizeBytes: 4096,
23
+ importedAt: DateTime.utc(2026, 6, 21),
24
+ );
25
+ SharedPreferences.setMockInitialValues({
26
+ storageKey: json.encode([metadata.toJson()]),
27
+ });
28
+ final prefs = await SharedPreferences.getInstance();
29
+ final container = ProviderContainer(
30
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
31
+ );
32
+ addTearDown(container.dispose);
33
+
34
+ final models = container.read(onDeviceModelsProvider);
35
+ final imported = models.singleWhere((model) => model.id == metadata.id);
36
+
37
+ expect(models.map((model) => model.id), contains('gemma4-e2b-instruct'));
38
+ expect(imported.runtime, OnDeviceModelRuntime.llamaCpp);
39
+ expect(imported.format, OnDeviceModelFormat.gguf);
40
+ expect(imported.localPath, metadata.filePath);
41
+ expect(imported.isImported, isTrue);
42
+ },
43
+ );
44
+
45
+ test(
46
+ 'downloadedModelsProvider treats imported GGUF models as local',
47
+ () async {
48
+ final metadata = ImportedGgufModelMetadata(
49
+ id: 'gguf-downloaded',
50
+ name: 'Downloaded GGUF',
51
+ filePath: '/tmp/downloaded.gguf',
52
+ fileSizeBytes: 4096,
53
+ importedAt: DateTime.utc(2026, 6, 21),
54
+ );
55
+ SharedPreferences.setMockInitialValues({
56
+ storageKey: json.encode([metadata.toJson()]),
57
+ });
58
+ final prefs = await SharedPreferences.getInstance();
59
+ final container = ProviderContainer(
60
+ overrides: [
61
+ sharedPreferencesProvider.overrideWithValue(prefs),
62
+ onDeviceGemmaServiceProvider.overrideWithValue(
63
+ _FakeOnDeviceGemmaService(),
64
+ ),
65
+ ],
66
+ );
67
+ addTearDown(container.dispose);
68
+
69
+ final downloaded = await container.read(downloadedModelsProvider.future);
70
+
71
+ expect(downloaded, contains('gguf-downloaded'));
72
+ },
73
+ );
74
+}
75
+
76
+class _FakeOnDeviceGemmaService extends OnDeviceGemmaService {
77
+ @override
78
+ Future<List<String>> getInstalledModelIds() async {
79
+ return ['gemma4-e2b-instruct'];
80
+ }
81
+}
test/features/servers/server_api_service_test.dart
+105
@@ -18,6 +18,37 @@ class TestInterceptor extends Interceptor {
18
}
19
}
20
21
+class RequestRecordingInterceptor extends Interceptor {
22
+ final List<RequestOptions> requests = [];
23
+ final int statusCode;
24
+ RequestRecordingInterceptor({this.statusCode = 200});
25
+
26
+ @override
27
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
28
+ requests.add(options);
29
+ handler.resolve(
30
+ Response(
31
+ requestOptions: options,
32
+ statusCode: statusCode,
33
+ data: {'status': 'ok'},
34
+ ),
35
+ );
36
+ }
37
+}
38
+
39
+class FailingInterceptor extends Interceptor {
40
+ @override
41
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
42
+ handler.reject(
43
+ DioException(
44
+ requestOptions: options,
45
+ type: DioExceptionType.connectionError,
46
+ message: 'connection refused',
47
+ ),
48
+ );
49
+ }
50
+}
51
+
52
void main() {
53
group('ServerApiService - OpenAI/llama.cpp model list parsing', () {
54
late Server testServer;
@@ -224,4 +255,78 @@ void main() {
255
expect(models.first.architecture, isNull);
256
});
257
});
258
+
259
+ group('ServerApiService - Ollama load behavior', () {
260
+ test('loadModel is a no-op for Ollama (does not POST /api/generate)', () async {
261
+ final ollamaServer = Server(
262
+ id: 'test-ollama',
263
+ name: 'Test Ollama',
264
+ type: ServerType.ollama,
265
+ host: 'localhost',
266
+ port: 11434,
267
+ createdAt: DateTime.now(),
268
+ lastConnectedAt: DateTime.now(),
269
+ );
270
+
271
+ final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
272
+ final service = ServerApiService(dio);
273
+
274
+ await service.loadModel(ollamaServer, 'llama3.2:latest');
275
+ await service.loadModelWithInstanceId(ollamaServer, 'llama3.2:latest');
276
+
277
+ final recorder =
278
+ dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
279
+ as RequestRecordingInterceptor;
280
+ expect(recorder.requests, isEmpty,
281
+ reason: 'Ollama should not issue an explicit load request; '
282
+ 'calling /api/generate without a prompt can trigger an '
283
+ 'auto-pull of the model from the Ollama registry.');
284
+ });
285
+
286
+ test('unloadModel for Ollama posts keep_alive=0 and a valid payload', () async {
287
+ final ollamaServer = Server(
288
+ id: 'test-ollama',
289
+ name: 'Test Ollama',
290
+ type: ServerType.ollama,
291
+ host: 'localhost',
292
+ port: 11434,
293
+ createdAt: DateTime.now(),
294
+ lastConnectedAt: DateTime.now(),
295
+ );
296
+
297
+ final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
298
+ final service = ServerApiService(dio);
299
+
300
+ await service.unloadModel(ollamaServer, 'llama3.2:latest');
301
+
302
+ final recorder =
303
+ dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
304
+ as RequestRecordingInterceptor;
305
+ expect(recorder.requests, hasLength(1));
306
+ expect(recorder.requests.first.path, endsWith('/api/generate'));
307
+ expect(recorder.requests.first.data, containsPair('keep_alive', 0));
308
+ expect(recorder.requests.first.data, containsPair('model', 'llama3.2:latest'));
309
+ expect(recorder.requests.first.data, containsPair('prompt', ''));
310
+ });
311
+
312
+ test('unloadModel for Ollama surfaces failures', () async {
313
+ final ollamaServer = Server(
314
+ id: 'test-ollama',
315
+ name: 'Test Ollama',
316
+ type: ServerType.ollama,
317
+ host: 'localhost',
318
+ port: 11434,
319
+ createdAt: DateTime.now(),
320
+ lastConnectedAt: DateTime.now(),
321
+ );
322
+
323
+ final dio = Dio()..interceptors.add(FailingInterceptor());
324
+ final service = ServerApiService(dio);
325
+
326
+ expect(
327
+ () => service.unloadModel(ollamaServer, 'llama3.2:latest'),
328
+ throwsA(isA<Exception>()),
329
+ );
330
+ });
331
+ });
332
}