@tduncan / Cortana / commits / 53fd8d6

feat: implement Hugging Face authentication for on-device gated models and update Ollama interaction logic.

abdulmominsakib committed Jun 24, 2026 at 15:20 UTC 53fd8d67717aca7aeb1c2f4d56e218e14c0e2312
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 }