@tduncan / Cortana / commits / 13ee9bf

feat: add gguf models from huggingface directly

abdulmominsakib committed Jun 29, 2026 at 22:56 UTC 13ee9bf78d2091478857d49d1d822c97af153c2c
11 files changed +1659 -132
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+72 -10
@@ -90,7 +90,9 @@ class AssistantBubble extends StatelessWidget {
90 _formatTime(message.createdAt),
91 style: TextStyle(
92 fontSize: 11,
93 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
93 + color: isDark
94 + ? AppColors.darkMutedText
95 + : AppColors.lightMutedText,
96 ),
97 ),
98 if (message.status == MessageStatus.error) ...[
@@ -168,22 +170,82 @@ class _StreamingContentState extends State<_StreamingContent> {
170
171 @override
172 Widget build(BuildContext context) {
171 - return MarkdownBodyContent(
172 - content: _visibleContent,
173 - isDark: widget.isDark,
174 - );
173 + return MarkdownBodyContent(content: _visibleContent, isDark: widget.isDark);
174 }
175 }
176
178 -class _StreamingIndicator extends StatelessWidget {
177 +class _StreamingIndicator extends StatefulWidget {
178 const _StreamingIndicator();
179
180 + @override
181 + State<_StreamingIndicator> createState() => _StreamingIndicatorState();
182 +}
183 +
184 +class _StreamingIndicatorState extends State<_StreamingIndicator>
185 + with SingleTickerProviderStateMixin {
186 + late final AnimationController _controller;
187 + late final List<Animation<double>> _animations;
188 +
189 + @override
190 + void initState() {
191 + super.initState();
192 + _controller = AnimationController(
193 + duration: const Duration(milliseconds: 1100),
194 + vsync: this,
195 + )..repeat();
196 +
197 + _animations = List.generate(3, (index) {
198 + return Tween<double>(begin: 0, end: 1).animate(
199 + CurvedAnimation(
200 + parent: _controller,
201 + curve: Interval(
202 + index * 0.2,
203 + 0.6 + index * 0.2,
204 + curve: Curves.easeInOut,
205 + ),
206 + ),
207 + );
208 + });
209 + }
210 +
211 + @override
212 + void dispose() {
213 + _controller.dispose();
214 + super.dispose();
215 + }
216 +
217 @override
218 Widget build(BuildContext context) {
183 - return const SizedBox(
184 - width: 12,
185 - height: 12,
186 - child: CircularProgressIndicator(strokeWidth: 2),
219 + final isDark = Theme.of(context).brightness == Brightness.dark;
220 + final dotColor = isDark
221 + ? AppColors.darkMutedText
222 + : AppColors.lightMutedText;
223 +
224 + return Row(
225 + mainAxisSize: MainAxisSize.min,
226 + children: List.generate(3, (index) {
227 + return AnimatedBuilder(
228 + animation: _animations[index],
229 + builder: (context, child) {
230 + return Container(
231 + margin: EdgeInsets.only(right: index == 2 ? 0 : 4),
232 + child: Transform.translate(
233 + offset: Offset(0, -2.5 * _animations[index].value),
234 + child: Container(
235 + width: 5,
236 + height: 5,
237 + decoration: BoxDecoration(
238 + color: dotColor.withValues(
239 + alpha: 0.35 + 0.65 * _animations[index].value,
240 + ),
241 + shape: BoxShape.circle,
242 + ),
243 + ),
244 + ),
245 + );
246 + },
247 + );
248 + }),
249 );
250 }
251 }
lib/features/on_device/components/model_card.dart
+84 -22
@@ -71,11 +71,7 @@ class ModelCard extends ConsumerWidget {
71 child: Column(
72 crossAxisAlignment: CrossAxisAlignment.start,
73 children: [
74 - _ModelCardHeader(
75 - model: model,
76 - theme: theme,
77 - l10n: l10n,
78 - ),
74 + _ModelCardHeader(model: model, theme: theme, l10n: l10n),
75 if (!Platform.isIOS &&
76 deviceMemory != null &&
77 deviceMemory!.isOversized(model.minRamMb))
@@ -161,7 +157,7 @@ class _ModelCardHeader extends StatelessWidget {
157 borderRadius: BorderRadius.circular(4),
158 ),
159 child: Text(
164 - 'Imported',
160 + model.importedSourceLabel ?? 'Imported',
161 style: theme.textTheme.labelSmall?.copyWith(
162 color: Colors.blue,
163 fontWeight: FontWeight.bold,
@@ -190,7 +186,11 @@ class _MemoryWarningBadge extends StatelessWidget {
186 child: Row(
187 mainAxisSize: MainAxisSize.min,
188 children: [
193 - const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
189 + const Icon(
190 + Icons.warning_amber_rounded,
191 + color: Colors.orange,
192 + size: 14,
193 + ),
194 const SizedBox(width: 6),
195 Text(
196 l10n.may_be_large,
@@ -220,7 +220,9 @@ class _CapabilityChips extends StatelessWidget {
220 if (model.supportsThinking) 'Thinking',
221 if (model.supportsVision) 'Vision',
222 model.languagesLabel,
223 - if (model.backendNote != null) model.backendNote!,
223 + if (model.backendNote != null &&
224 + model.backendNote!.toLowerCase() != 'llama.cpp')
225 + model.backendNote!,
226 if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
227 ];
228
@@ -254,30 +256,86 @@ class _CapabilityChips extends StatelessWidget {
256 }
257
258 class _ModelMetaRow extends StatelessWidget {
257 - const _ModelMetaRow({required this.model, required this.theme, required this.l10n});
259 + const _ModelMetaRow({
260 + required this.model,
261 + required this.theme,
262 + required this.l10n,
263 + });
264 final OnDeviceModel model;
265 final ThemeData theme;
266 final AppLocalizations l10n;
267
268 @override
269 Widget build(BuildContext context) {
264 - return Row(
270 + return Column(
271 + crossAxisAlignment: CrossAxisAlignment.start,
272 children: [
266 - Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
267 - const SizedBox(width: 12),
268 - Text(model.license, style: theme.textTheme.labelMedium),
269 - const SizedBox(width: 12),
270 - Text(
271 - l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
272 - style: theme.textTheme.labelMedium?.copyWith(
273 - color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
274 - ),
273 + Wrap(
274 + spacing: 8,
275 + runSpacing: 8,
276 + children: [
277 + _MetaPill(label: model.fileSizeFormatted, theme: theme),
278 + if (!model.isImported)
279 + _MetaPill(label: model.license, theme: theme),
280 + _MetaPill(
281 + label: l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
282 + theme: theme,
283 + muted: true,
284 + ),
285 + ],
286 ),
287 + if (model.isImported) ...[
288 + const SizedBox(height: 8),
289 + Text(
290 + model.fileName,
291 + maxLines: 1,
292 + overflow: TextOverflow.ellipsis,
293 + style: theme.textTheme.labelSmall?.copyWith(
294 + color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
295 + ),
296 + ),
297 + ],
298 ],
299 );
300 }
301 }
302
303 +class _MetaPill extends StatelessWidget {
304 + const _MetaPill({
305 + required this.label,
306 + required this.theme,
307 + this.muted = false,
308 + });
309 +
310 + final String label;
311 + final ThemeData theme;
312 + final bool muted;
313 +
314 + @override
315 + Widget build(BuildContext context) {
316 + final color = muted
317 + ? theme.colorScheme.onSurface.withValues(alpha: 0.62)
318 + : theme.colorScheme.onSurface;
319 +
320 + return Container(
321 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
322 + decoration: BoxDecoration(
323 + color: theme.colorScheme.surfaceContainerHighest.withValues(
324 + alpha: 0.45,
325 + ),
326 + borderRadius: BorderRadius.circular(999),
327 + ),
328 + child: Text(
329 + label,
330 + style: theme.textTheme.labelSmall?.copyWith(
331 + color: color,
332 + fontWeight: FontWeight.w600,
333 + ),
334 + ),
335 + );
336 + }
337 +}
338 +
339 class _ModelCardActions extends ConsumerWidget {
340 const _ModelCardActions({
341 required this.model,
@@ -395,7 +453,8 @@ class _DownloadedActions extends ConsumerWidget {
453 else if (isLoaded)
454 ShadButton.outline(
455 size: ShadButtonSize.sm,
398 - onPressed: () => ref.read(onDeviceEngineProvider.notifier).unloadModel(),
456 + onPressed: () =>
457 + ref.read(onDeviceEngineProvider.notifier).unloadModel(),
458 child: Text(l10n.unload),
459 )
460 else
@@ -487,7 +546,9 @@ class _DownloadingActions extends ConsumerWidget {
546 child: LinearProgressIndicator(
547 value: downloadProgress?.progress ?? 0.0,
548 minHeight: 6,
490 - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
549 + backgroundColor: theme.colorScheme.primary.withValues(
550 + alpha: 0.1,
551 + ),
552 valueColor: AlwaysStoppedAnimation<Color>(
553 theme.colorScheme.primary,
554 ),
@@ -499,7 +560,8 @@ class _DownloadingActions extends ConsumerWidget {
560 children: [
561 Text(
562 l10n.download_progress(
502 - ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(1),
563 + ((downloadProgress?.progress ?? 0) * 100)
564 + .toStringAsFixed(1),
565 downloadProgress?.speedFormatted ?? '0 B/s',
566 ),
567 style: theme.textTheme.labelSmall?.copyWith(
lib/features/on_device/components/on_device_picker_section.dart
+101 -33
@@ -82,39 +82,97 @@ class OnDevicePickerSection extends ConsumerWidget {
82 : l10n.no_models_match(searchQuery),
83 style: TextStyle(
84 fontSize: 14,
85 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
85 + color: isDark
86 + ? AppColors.darkMutedText
87 + : AppColors.lightMutedText,
88 ),
89 ),
90 );
91 }
92
91 - return ListView.builder(
92 - itemCount: filtered.length,
93 - itemBuilder: (context, index) {
94 - final model = filtered[index];
95 - final isDownloaded = downloadedIds.contains(model.id);
96 - final isLoaded = engineState.loadedModelId == model.id;
97 - final isCurrentlyLoading =
98 - engineState.status == OnDeviceEngineStatus.loading &&
99 - engineState.loadedModelId == model.id;
100 - final isSelected = selectedModelId == model.id;
101 -
102 - return _OnDeviceModelTile(
103 - model: model,
104 - isDownloaded: isDownloaded,
105 - isLoaded: isLoaded,
106 - isCurrentlyLoading: isCurrentlyLoading,
107 - isSelected: isSelected,
108 - isDark: isDark,
109 - deviceMemory: deviceMemoryAsync.value,
93 + final importedModels =
94 + filtered.where((model) => model.isImported).toList()..sort(
95 + (a, b) => (b.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
96 + .compareTo(
97 + a.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0),
98 + ),
99 );
111 - },
100 + final downloadableModels = filtered
101 + .where((model) => !model.isImported)
102 + .toList();
103 +
104 + Widget buildTile(OnDeviceModel model) {
105 + final isDownloaded = downloadedIds.contains(model.id);
106 + final isLoaded = engineState.loadedModelId == model.id;
107 + final isCurrentlyLoading =
108 + engineState.status == OnDeviceEngineStatus.loading &&
109 + engineState.loadedModelId == model.id;
110 + final isSelected = selectedModelId == model.id;
111 +
112 + return _OnDeviceModelTile(
113 + model: model,
114 + isDownloaded: isDownloaded,
115 + isLoaded: isLoaded,
116 + isCurrentlyLoading: isCurrentlyLoading,
117 + isSelected: isSelected,
118 + isDark: isDark,
119 + deviceMemory: deviceMemoryAsync.value,
120 + );
121 + }
122 +
123 + return ListView(
124 + children: [
125 + if (importedModels.isNotEmpty) ...[
126 + const SizedBox(height: 4),
127 + const _PickerSectionLabel(
128 + title: 'Imported GGUF',
129 + subtitle: 'Already available on this device',
130 + ),
131 + ...importedModels.map(buildTile),
132 + const SizedBox(height: 8),
133 + ],
134 + if (downloadableModels.isNotEmpty) ...[
135 + _PickerSectionLabel(
136 + title: l10n.available_models,
137 + subtitle: 'Curated on-device models',
138 + ),
139 + ...downloadableModels.map(buildTile),
140 + ],
141 + ],
142 );
143 },
144 );
145 }
146 }
147
148 +class _PickerSectionLabel extends StatelessWidget {
149 + const _PickerSectionLabel({required this.title, required this.subtitle});
150 +
151 + final String title;
152 + final String subtitle;
153 +
154 + @override
155 + Widget build(BuildContext context) {
156 + final isDark = Theme.of(context).brightness == Brightness.dark;
157 + final muted = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
158 +
159 + return Padding(
160 + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
161 + child: Column(
162 + crossAxisAlignment: CrossAxisAlignment.start,
163 + children: [
164 + Text(
165 + title,
166 + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
167 + ),
168 + const SizedBox(height: 2),
169 + Text(subtitle, style: TextStyle(fontSize: 11, color: muted)),
170 + ],
171 + ),
172 + );
173 + }
174 +}
175 +
176 class _OnDeviceModelTile extends ConsumerWidget {
177 const _OnDeviceModelTile({
178 required this.model,
@@ -224,9 +282,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
282 color: Colors.blue.withValues(alpha: 0.1),
283 borderRadius: BorderRadius.circular(4),
284 ),
227 - child: const Text(
228 - 'Imported',
229 - style: TextStyle(
285 + child: Text(
286 + model.importedSourceLabel ?? 'Imported',
287 + style: const TextStyle(
288 fontSize: 9,
289 fontWeight: FontWeight.bold,
290 color: Colors.blue,
@@ -302,11 +360,13 @@ class _OnDeviceModelTile extends ConsumerWidget {
360 MetadataChip(label: 'GGUF', isDark: isDark),
361 if (model.runtime == OnDeviceModelRuntime.llamaCpp)
362 MetadataChip(label: 'llama.cpp', isDark: isDark),
305 - MetadataChip(label: model.license, isDark: isDark),
306 - MetadataChip(
307 - label: model.parameterLabel,
308 - isDark: isDark,
309 - ),
363 + if (!model.isImported)
364 + MetadataChip(label: model.license, isDark: isDark),
365 + if (!model.isImported)
366 + MetadataChip(
367 + label: model.parameterLabel,
368 + isDark: isDark,
369 + ),
370 ],
371 ),
372 ],
@@ -349,7 +409,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
409 icon: Icon(
410 Icons.delete_outline,
411 size: 18,
352 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
412 + color: isDark
413 + ? AppColors.darkMutedText
414 + : AppColors.lightMutedText,
415 ),
416 tooltip: l10n.delete,
417 onPressed: () => _deleteModel(context, ref),
@@ -359,7 +421,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
421 icon: Icon(
422 Icons.close,
423 size: 16,
362 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
424 + color: isDark
425 + ? AppColors.darkMutedText
426 + : AppColors.lightMutedText,
427 ),
428 tooltip: l10n.cancel,
429 onPressed: () => ref
@@ -371,7 +435,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
435 icon: Icon(
436 Icons.cloud_download_outlined,
437 size: 18,
374 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
438 + color: isDark
439 + ? AppColors.darkMutedText
440 + : AppColors.lightMutedText,
441 ),
442 tooltip: l10n.download,
443 onPressed: () async {
@@ -398,7 +464,9 @@ class _OnDeviceModelTile extends ConsumerWidget {
464 l10n.not_downloaded,
465 style: TextStyle(
466 fontSize: 11,
401 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
467 + color: isDark
468 + ? AppColors.darkMutedText
469 + : AppColors.lightMutedText,
470 ),
471 ),
472 ],
lib/features/on_device/data/imported_gguf_model_repository.dart
+234 -16
@@ -2,6 +2,7 @@ import 'dart:convert';
2 import 'dart:io';
3 import 'dart:math';
4
5 +import 'package:dio/dio.dart';
6 import 'package:path/path.dart' as p;
7 import 'package:path_provider/path_provider.dart';
8 import 'package:shared_preferences/shared_preferences.dart';
@@ -14,6 +15,8 @@ class ImportedGgufModelMetadata {
15 final String filePath;
16 final int fileSizeBytes;
17 final DateTime importedAt;
18 + final OnDeviceImportedSource source;
19 + final String? sourceUrl;
20
21 const ImportedGgufModelMetadata({
22 required this.id,
@@ -21,19 +24,38 @@ class ImportedGgufModelMetadata {
24 required this.filePath,
25 required this.fileSizeBytes,
26 required this.importedAt,
27 + required this.source,
28 + this.sourceUrl,
29 });
30
31 String get fileName => p.basename(filePath);
32
33 + String get sourceLabel {
34 + switch (source) {
35 + case OnDeviceImportedSource.localFile:
36 + return 'Local file';
37 + case OnDeviceImportedSource.huggingFace:
38 + return 'Hugging Face';
39 + }
40 + }
41 +
42 + int get estimatedMinRamMb {
43 + final fileSizeMb = fileSizeBytes / (1024 * 1024);
44 + return max(2048, (fileSizeMb * 1.3).ceil());
45 + }
46 +
47 OnDeviceModel toOnDeviceModel() {
48 + final isHuggingFace = source == OnDeviceImportedSource.huggingFace;
49 return OnDeviceModel(
50 id: id,
51 name: name,
32 - huggingFaceUrl: '',
52 + huggingFaceUrl: isHuggingFace ? (sourceUrl ?? '') : '',
53 fileSizeBytes: fileSizeBytes,
34 - license: 'Local file',
35 - description: 'Imported GGUF model for local llama.cpp inference.',
36 - minRamMb: 2048,
54 + license: sourceLabel,
55 + description: isHuggingFace
56 + ? 'Imported GGUF model from Hugging Face for local llama.cpp inference.'
57 + : 'Imported GGUF model from a local file for local llama.cpp inference.',
58 + minRamMb: estimatedMinRamMb,
59 parameterLabel: 'GGUF',
60 bestFor: 'Local GGUF inference',
61 languagesLabel: 'Local',
@@ -44,6 +66,7 @@ class ImportedGgufModelMetadata {
66 localPath: filePath,
67 importedAt: importedAt,
68 isImported: true,
69 + importedSource: source,
70 );
71 }
72
@@ -54,6 +77,8 @@ class ImportedGgufModelMetadata {
77 'filePath': filePath,
78 'fileSizeBytes': fileSizeBytes,
79 'importedAt': importedAt.toIso8601String(),
80 + 'source': source.name,
81 + 'sourceUrl': sourceUrl,
82 };
83 }
84
@@ -64,8 +89,20 @@ class ImportedGgufModelMetadata {
89 filePath: json['filePath'] as String,
90 fileSizeBytes: (json['fileSizeBytes'] as num).toInt(),
91 importedAt: DateTime.parse(json['importedAt'] as String),
92 + source: _sourceFromJson(json['source']),
93 + sourceUrl: json['sourceUrl'] as String?,
94 );
95 }
96 +
97 + static OnDeviceImportedSource _sourceFromJson(dynamic value) {
98 + if (value is String) {
99 + return OnDeviceImportedSource.values.firstWhere(
100 + (source) => source.name == value,
101 + orElse: () => OnDeviceImportedSource.localFile,
102 + );
103 + }
104 + return OnDeviceImportedSource.localFile;
105 + }
106 }
107
108 class ImportedGgufModelRepository {
@@ -73,9 +110,10 @@ class ImportedGgufModelRepository {
110 static const _storageDirName = 'imported_gguf_models';
111
112 final SharedPreferences _prefs;
113 + final Dio _dio;
114 final Random _random;
115
78 - ImportedGgufModelRepository(this._prefs, {Random? random})
116 + ImportedGgufModelRepository(this._prefs, this._dio, {Random? random})
117 : _random = random ?? Random.secure();
118
119 List<ImportedGgufModelMetadata> load() {
@@ -127,19 +165,102 @@ class ImportedGgufModelRepository {
165 final id = _createId(originalName);
166 final fileName = '$id-${_sanitizeFileName(originalName)}';
167 final target = File(p.join(dir.path, fileName));
130 - await source.copy(target.path);
168
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 - );
169 + try {
170 + await source.copy(target.path);
171 + await _validateGgufFile(target);
172
140 - final current = load();
141 - await saveAll([...current, metadata]);
142 - return metadata;
173 + final metadata = ImportedGgufModelMetadata(
174 + id: id,
175 + name: _displayNameFromFileName(originalName),
176 + filePath: target.path,
177 + fileSizeBytes: await target.length(),
178 + importedAt: DateTime.now(),
179 + source: OnDeviceImportedSource.localFile,
180 + );
181 +
182 + final current = load();
183 + await saveAll([...current, metadata]);
184 + return metadata;
185 + } catch (_) {
186 + if (await target.exists()) {
187 + await target.delete();
188 + }
189 + rethrow;
190 + }
191 + }
192 +
193 + Future<ImportedGgufModelMetadata> importFromHuggingFaceUrl(
194 + String sourceUrl, {
195 + String? token,
196 + void Function(int receivedBytes, int totalBytes)? onProgress,
197 + CancelToken? cancelToken,
198 + }) async {
199 + final normalizedUrl = _normalizeHuggingFaceGgufUrl(sourceUrl);
200 + final originalName = _fileNameFromUri(normalizedUrl);
201 + final dir = await _modelsDirectory();
202 + final id = _createId(originalName);
203 + final fileName = '$id-${_sanitizeFileName(originalName)}';
204 + final targetPath = p.join(dir.path, fileName);
205 + final tempPath = '$targetPath.part';
206 + final tempFile = File(tempPath);
207 + final targetFile = File(targetPath);
208 +
209 + if (await tempFile.exists()) {
210 + await tempFile.delete();
211 + }
212 +
213 + try {
214 + await _dio.download(
215 + normalizedUrl.toString(),
216 + tempPath,
217 + cancelToken: cancelToken,
218 + onReceiveProgress: onProgress,
219 + options: Options(
220 + headers: {
221 + if (token != null && token.isNotEmpty)
222 + HttpHeaders.authorizationHeader: 'Bearer $token',
223 + },
224 + followRedirects: true,
225 + receiveTimeout: const Duration(hours: 12),
226 + sendTimeout: const Duration(minutes: 5),
227 + validateStatus: (status) =>
228 + status != null && status >= 200 && status < 400,
229 + ),
230 + );
231 +
232 + if (!await tempFile.exists() || await tempFile.length() <= 0) {
233 + throw const FileSystemException(
234 + 'The downloaded GGUF file was empty or missing.',
235 + );
236 + }
237 +
238 + await _validateGgufFile(tempFile);
239 + await tempFile.rename(targetPath);
240 +
241 + final metadata = ImportedGgufModelMetadata(
242 + id: id,
243 + name: _displayNameFromFileName(originalName),
244 + filePath: targetFile.path,
245 + fileSizeBytes: await targetFile.length(),
246 + importedAt: DateTime.now(),
247 + source: OnDeviceImportedSource.huggingFace,
248 + sourceUrl: normalizedUrl.toString(),
249 + );
250 +
251 + final current = load();
252 + await saveAll([...current, metadata]);
253 + return metadata;
254 + } on DioException catch (e) {
255 + if (CancelToken.isCancel(e)) {
256 + throw const HttpException('GGUF import canceled.');
257 + }
258 + throw HttpException(_friendlyDioError(e));
259 + } finally {
260 + if (await tempFile.exists()) {
261 + await tempFile.delete();
262 + }
263 + }
264 }
265
266 Future<void> delete(String id) async {
@@ -174,6 +295,103 @@ class ImportedGgufModelRepository {
295 return dir;
296 }
297
298 + Uri _normalizeHuggingFaceGgufUrl(String input) {
299 + final trimmed = input.trim();
300 + if (trimmed.isEmpty) {
301 + throw const FormatException('Enter a Hugging Face GGUF URL.');
302 + }
303 +
304 + final withScheme =
305 + trimmed.startsWith('http://') || trimmed.startsWith('https://')
306 + ? trimmed
307 + : 'https://huggingface.co/$trimmed';
308 +
309 + final uri = Uri.parse(withScheme);
310 + final host = uri.host.toLowerCase();
311 + if (!_isAllowedHuggingFaceHost(host)) {
312 + throw const FormatException(
313 + 'Only official Hugging Face GGUF URLs are supported.',
314 + );
315 + }
316 +
317 + if (uri.scheme.toLowerCase() != 'https') {
318 + throw const FormatException(
319 + 'Use an HTTPS Hugging Face URL for GGUF import.',
320 + );
321 + }
322 +
323 + final normalizedSegments = [...uri.pathSegments];
324 + final blobIndex = normalizedSegments.indexOf('blob');
325 + if (blobIndex != -1) {
326 + normalizedSegments[blobIndex] = 'resolve';
327 + }
328 +
329 + final normalizedUri = uri.replace(pathSegments: normalizedSegments);
330 + final fileName = _fileNameFromUri(normalizedUri);
331 + if (!fileName.toLowerCase().endsWith('.gguf')) {
332 + throw const FormatException(
333 + 'The Hugging Face URL must point directly to a .gguf file.',
334 + );
335 + }
336 + return normalizedUri;
337 + }
338 +
339 + String _fileNameFromUri(Uri uri) {
340 + final segments = uri.pathSegments
341 + .where((segment) => segment.isNotEmpty)
342 + .toList();
343 + if (segments.isEmpty) {
344 + throw const FormatException('Unable to determine the GGUF file name.');
345 + }
346 +
347 + final fileName = Uri.decodeComponent(segments.last);
348 + if (!fileName.toLowerCase().endsWith('.gguf')) {
349 + throw const FormatException('Unable to determine the GGUF file name.');
350 + }
351 + return fileName;
352 + }
353 +
354 + bool _isAllowedHuggingFaceHost(String host) {
355 + return host == 'huggingface.co' ||
356 + host == 'www.huggingface.co' ||
357 + host == 'hf.co';
358 + }
359 +
360 + Future<void> _validateGgufFile(File file) async {
361 + final bytes = await file.openRead(0, 4).expand((chunk) => chunk).toList();
362 + final isGguf =
363 + bytes.length == 4 &&
364 + bytes[0] == 0x47 &&
365 + bytes[1] == 0x47 &&
366 + bytes[2] == 0x55 &&
367 + bytes[3] == 0x46;
368 +
369 + if (!isGguf) {
370 + throw const FormatException(
371 + 'The selected file is not a valid GGUF model.',
372 + );
373 + }
374 + }
375 +
376 + String _friendlyDioError(DioException error) {
377 + final statusCode = error.response?.statusCode;
378 + if (statusCode == 401 || statusCode == 403) {
379 + return 'Hugging Face denied access to this file. Add a valid Hugging Face token in Settings for gated or private models.';
380 + }
381 + if (statusCode == 404) {
382 + return 'The GGUF file could not be found on Hugging Face.';
383 + }
384 + if (statusCode != null) {
385 + return 'Failed to download GGUF from Hugging Face (HTTP $statusCode).';
386 + }
387 + if (error.type == DioExceptionType.connectionTimeout ||
388 + error.type == DioExceptionType.sendTimeout ||
389 + error.type == DioExceptionType.receiveTimeout) {
390 + return 'The GGUF download timed out. Please try again on a stable connection.';
391 + }
392 + return error.message ?? 'Failed to download GGUF from Hugging Face.';
393 + }
394 +
395 String _createId(String fileName) {
396 final micros = DateTime.now().microsecondsSinceEpoch;
397 final suffix = _random.nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0');
lib/features/on_device/data/models/on_device_model.dart
+21
@@ -6,6 +6,8 @@ enum OnDeviceModelRuntime { gemma, llamaCpp }
6
7 enum OnDeviceModelFormat { litertlm, task, binary, gguf }
8
9 +enum OnDeviceImportedSource { localFile, huggingFace }
10 +
11 class OnDeviceModel {
12 final String id;
13 final String name;
@@ -28,6 +30,7 @@ class OnDeviceModel {
30 final String? localPath;
31 final DateTime? importedAt;
32 final bool isImported;
33 + final OnDeviceImportedSource? importedSource;
34 final bool requiresHuggingFaceToken;
35
36 const OnDeviceModel({
@@ -52,6 +55,7 @@ class OnDeviceModel {
55 this.localPath,
56 this.importedAt,
57 this.isImported = false,
58 + this.importedSource,
59 this.requiresHuggingFaceToken = false,
60 });
61
@@ -73,6 +77,23 @@ class OnDeviceModel {
77
78 bool get isLlamaCpp => runtime == OnDeviceModelRuntime.llamaCpp;
79
80 + bool get isImportedFromLocalFile =>
81 + importedSource == OnDeviceImportedSource.localFile;
82 +
83 + bool get isImportedFromHuggingFace =>
84 + importedSource == OnDeviceImportedSource.huggingFace;
85 +
86 + String? get importedSourceLabel {
87 + switch (importedSource) {
88 + case OnDeviceImportedSource.localFile:
89 + return 'Local file';
90 + case OnDeviceImportedSource.huggingFace:
91 + return 'Hugging Face';
92 + case null:
93 + return null;
94 + }
95 + }
96 +
97 ModelType get flutterGemmaModelType {
98 switch (id) {
99 case 'qwen3-0.6b':
lib/features/on_device/providers/on_device_providers.dart
+40 -4
@@ -1,6 +1,7 @@
1 import 'dart:io';
2
3 -import 'package:flutter_gemma/flutter_gemma.dart';
3 +import 'package:dio/dio.dart';
4 +import 'package:flutter_gemma/flutter_gemma.dart' show PreferredBackend;
5 import 'package:flutter_riverpod/flutter_riverpod.dart';
6
7 import '../../../core/logger/app_logger.dart';
@@ -8,6 +9,7 @@ import '../../../core/models/enums.dart';
9 import '../../../core/providers/app_providers.dart';
10 import '../../../core/providers/chat_background_service_provider.dart';
11 import '../../../core/providers/review_prompt_providers.dart';
12 +import '../../../core/providers/service_providers.dart';
13 import '../../../core/providers/storage_providers.dart';
14 import '../data/imported_gguf_model_repository.dart';
15 import '../data/models/on_device_model.dart';
@@ -34,7 +36,10 @@ final onDeviceLlamaServiceProvider = Provider<OnDeviceLlamaService>((ref) {
36
37 final importedGgufModelRepositoryProvider =
38 Provider<ImportedGgufModelRepository>((ref) {
37 - return ImportedGgufModelRepository(ref.read(sharedPreferencesProvider));
39 + return ImportedGgufModelRepository(
40 + ref.read(sharedPreferencesProvider),
41 + ref.read(dioProvider),
42 + );
43 });
44
45 final importedGgufModelsProvider =
@@ -108,17 +113,46 @@ class OnDeviceEngineState {
113 }
114
115 class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
116 + bool _hasScheduledInitialPrune = false;
117 +
118 ImportedGgufModelRepository get _repository =>
119 ref.read(importedGgufModelRepositoryProvider);
120
121 @override
122 List<OnDeviceModel> build() {
123 + if (!_hasScheduledInitialPrune) {
124 + _hasScheduledInitialPrune = true;
125 + Future.microtask(() async {
126 + if (ref.mounted) {
127 + await pruneMissing();
128 + }
129 + });
130 + }
131 return _repository.load().map((model) => model.toOnDeviceModel()).toList();
132 }
133
119 - Future<void> importModel(String sourcePath) async {
134 + Future<OnDeviceModel> importModel(String sourcePath) async {
135 final metadata = await _repository.importFromPath(sourcePath);
121 - state = [...state, metadata.toOnDeviceModel()];
136 + final model = metadata.toOnDeviceModel();
137 + state = [...state, model];
138 + return model;
139 + }
140 +
141 + Future<OnDeviceModel> importModelFromHuggingFaceUrl(
142 + String sourceUrl, {
143 + String? huggingFaceToken,
144 + void Function(int receivedBytes, int totalBytes)? onProgress,
145 + CancelToken? cancelToken,
146 + }) async {
147 + final metadata = await _repository.importFromHuggingFaceUrl(
148 + sourceUrl,
149 + token: huggingFaceToken,
150 + onProgress: onProgress,
151 + cancelToken: cancelToken,
152 + );
153 + final model = metadata.toOnDeviceModel();
154 + state = [...state, model];
155 + return model;
156 }
157
158 Future<void> deleteModel(String modelId) async {
@@ -142,6 +176,8 @@ class ImportedGgufModelsNotifier extends Notifier<List<OnDeviceModel>> {
176 /// disposed provider container.
177 Future<void> pruneMissing() async {
178 final existing = await _repository.loadExisting();
179 + if (!ref.mounted) return;
180 +
181 final keptIds = existing.map((m) => m.id).toSet();
182 if (keptIds.length == state.length &&
183 state.every((m) => keptIds.contains(m.id))) {
lib/features/on_device/views/model_manager_screen.dart
+1027 -26
@@ -1,21 +1,41 @@
1 +import 'package:dio/dio.dart';
2 import 'package:file_picker/file_picker.dart';
3 import 'package:flutter/foundation.dart';
4 import 'package:flutter/material.dart';
5 +import 'package:flutter/services.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 -import 'package:shadcn_ui/shadcn_ui.dart';
7 import 'package:localmind/core/models/enums.dart';
8 +import 'package:localmind/core/providers/app_providers.dart';
9 import 'package:localmind/l10n/app_localizations.dart';
10 import 'package:localmind/features/on_device/data/models/on_device_model.dart';
9 -import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11 import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
12 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
13 import 'package:localmind/features/on_device/components/memory_stats_panel.dart';
14 import 'package:localmind/features/on_device/components/model_card.dart';
15 +import 'package:url_launcher/url_launcher.dart';
16 +
17 +enum _ImportAction { localFile, huggingFace }
18
14 -class OnDeviceModelManagerScreen extends ConsumerWidget {
19 +class OnDeviceModelManagerScreen extends ConsumerStatefulWidget {
20 const OnDeviceModelManagerScreen({super.key});
21
22 @override
18 - Widget build(BuildContext context, WidgetRef ref) {
23 + ConsumerState<OnDeviceModelManagerScreen> createState() =>
24 + _OnDeviceModelManagerScreenState();
25 +}
26 +
27 +class _OnDeviceModelManagerScreenState
28 + extends ConsumerState<OnDeviceModelManagerScreen> {
29 + @override
30 + void initState() {
31 + super.initState();
32 + Future.microtask(
33 + () => ref.read(importedGgufModelsProvider.notifier).pruneMissing(),
34 + );
35 + }
36 +
37 + @override
38 + Widget build(BuildContext context) {
39 final l10n = AppLocalizations.of(context)!;
40 final theme = Theme.of(context);
41 final models = ref.watch(onDeviceModelsProvider);
@@ -23,32 +43,67 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
43 final engineState = ref.watch(onDeviceEngineProvider);
44 final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
45
46 + final importedModels = models.where((model) => model.isImported).toList()
47 + ..sort(
48 + (a, b) => (b.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
49 + .compareTo(a.importedAt ?? DateTime.fromMillisecondsSinceEpoch(0)),
50 + );
51 + final curatedModels = models.where((model) => !model.isImported).toList();
52 +
53 return Column(
54 children: [
55 _ManagerHeader(
56 l10n: l10n,
57 theme: theme,
31 - onImportGguf: () => _importGguf(context, ref),
58 + onImportLocalGguf: () => _importLocalGguf(context),
59 + onImportFromHuggingFace: () => _importFromHuggingFace(context),
60 ),
61 Expanded(
62 child: ListView(
63 padding: const EdgeInsets.all(16),
64 children: [
65 const MemoryStatsPanel(),
66 + _ImportOverviewCard(
67 + importedModels: importedModels,
68 + onImportLocalGguf: () => _importLocalGguf(context),
69 + onImportFromHuggingFace: () => _importFromHuggingFace(context),
70 + ),
71 _EngineStatusCard(
72 engineState: engineState,
73 models: models,
74 l10n: l10n,
75 theme: theme,
76 ),
44 - Text(
45 - l10n.available_models,
46 - style: theme.textTheme.titleMedium?.copyWith(
47 - fontWeight: FontWeight.bold,
77 + const SizedBox(height: 4),
78 + _SectionHeader(
79 + title: 'Imported GGUF models',
80 + subtitle: importedModels.isEmpty
81 + ? 'Import a GGUF from your device or add one from Hugging Face. Imported models run locally with llama.cpp.'
82 + : '${importedModels.length} imported model${importedModels.length == 1 ? '' : 's'} ready for local inference.',
83 + ),
84 + if (importedModels.isEmpty)
85 + _EmptyImportedModelsCard(
86 + onImportLocalGguf: () => _importLocalGguf(context),
87 + onImportFromHuggingFace: () =>
88 + _importFromHuggingFace(context),
89 + )
90 + else
91 + ...importedModels.map(
92 + (model) => ModelCard(
93 + model: model,
94 + downloadedAsync: downloadedAsync,
95 + downloadProgress: downloadProgress[model.id],
96 + engineState: engineState,
97 + ),
98 ),
99 + const SizedBox(height: 8),
100 + _SectionHeader(
101 + title: l10n.available_models,
102 + subtitle:
103 + 'Curated on-device models you can download and manage inside LocalMind.',
104 ),
50 - const SizedBox(height: 12),
51 - ...models.map(
105 + const SizedBox(height: 4),
106 + ...curatedModels.map(
107 (model) => ModelCard(
108 model: model,
109 downloadedAsync: downloadedAsync,
@@ -64,7 +119,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
119 );
120 }
121
67 - Future<void> _importGguf(BuildContext context, WidgetRef ref) async {
122 + Future<void> _importLocalGguf(BuildContext context) async {
123 final messenger = ScaffoldMessenger.of(context);
124 try {
125 final result = await FilePicker.pickFiles(
@@ -83,9 +138,11 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
138 return;
139 }
140
86 - await ref.read(importedGgufModelsProvider.notifier).importModel(path);
141 + final model = await ref
142 + .read(importedGgufModelsProvider.notifier)
143 + .importModel(path);
144 messenger.showSnackBar(
88 - const SnackBar(content: Text('GGUF model imported.')),
145 + SnackBar(content: Text('${model.name} imported from local file.')),
146 );
147 } catch (e) {
148 messenger.showSnackBar(
@@ -93,18 +150,36 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
150 );
151 }
152 }
153 +
154 + Future<void> _importFromHuggingFace(BuildContext context) async {
155 + final messenger = ScaffoldMessenger.of(context);
156 + final importedModel = await showDialog<OnDeviceModel>(
157 + context: context,
158 + barrierDismissible: false,
159 + builder: (_) => const _HuggingFaceGgufImportDialog(),
160 + );
161 +
162 + if (!mounted || importedModel == null) return;
163 + messenger.showSnackBar(
164 + SnackBar(
165 + content: Text('${importedModel.name} imported from Hugging Face.'),
166 + ),
167 + );
168 + }
169 }
170
171 class _ManagerHeader extends StatelessWidget {
172 const _ManagerHeader({
173 required this.l10n,
174 required this.theme,
102 - required this.onImportGguf,
175 + required this.onImportLocalGguf,
176 + required this.onImportFromHuggingFace,
177 });
178
179 final AppLocalizations l10n;
180 final ThemeData theme;
107 - final VoidCallback onImportGguf;
181 + final VoidCallback onImportLocalGguf;
182 + final VoidCallback onImportFromHuggingFace;
183
184 @override
185 Widget build(BuildContext context) {
@@ -152,13 +227,938 @@ class _ManagerHeader extends StatelessWidget {
227 ),
228 if (defaultTargetPlatform == TargetPlatform.android ||
229 defaultTargetPlatform == TargetPlatform.iOS)
155 - ShadButton.outline(
156 - size: ShadButtonSize.sm,
157 - onPressed: onImportGguf,
158 - child: const Text('Import GGUF'),
230 + PopupMenuButton<_ImportAction>(
231 + tooltip: 'Import GGUF',
232 + onSelected: (value) {
233 + switch (value) {
234 + case _ImportAction.localFile:
235 + onImportLocalGguf();
236 + case _ImportAction.huggingFace:
237 + onImportFromHuggingFace();
238 + }
239 + },
240 + itemBuilder: (context) => const [
241 + PopupMenuItem<_ImportAction>(
242 + value: _ImportAction.localFile,
243 + child: ListTile(
244 + contentPadding: EdgeInsets.zero,
245 + leading: Icon(Icons.folder_open_outlined),
246 + title: Text('Import local GGUF'),
247 + subtitle: Text('Copy a .gguf file from this device'),
248 + ),
249 + ),
250 + PopupMenuItem<_ImportAction>(
251 + value: _ImportAction.huggingFace,
252 + child: ListTile(
253 + contentPadding: EdgeInsets.zero,
254 + leading: Icon(Icons.cloud_download_outlined),
255 + title: Text('Import from Hugging Face'),
256 + subtitle: Text('Paste a GGUF URL or repo path'),
257 + ),
258 + ),
259 + ],
260 + child: Container(
261 + padding: const EdgeInsets.symmetric(
262 + horizontal: 12,
263 + vertical: 8,
264 + ),
265 + decoration: BoxDecoration(
266 + borderRadius: BorderRadius.circular(10),
267 + border: Border.all(
268 + color: theme.colorScheme.outline.withValues(alpha: 0.4),
269 + ),
270 + ),
271 + child: Row(
272 + mainAxisSize: MainAxisSize.min,
273 + children: [
274 + Icon(
275 + Icons.add_circle_outline,
276 + size: 18,
277 + color: theme.colorScheme.primary,
278 + ),
279 + const SizedBox(width: 8),
280 + const Text('Import GGUF'),
281 + ],
282 + ),
283 + ),
284 + ),
285 + ],
286 + ),
287 + );
288 + }
289 +}
290 +
291 +class _ImportOverviewCard extends StatelessWidget {
292 + const _ImportOverviewCard({
293 + required this.importedModels,
294 + required this.onImportLocalGguf,
295 + required this.onImportFromHuggingFace,
296 + });
297 +
298 + final List<OnDeviceModel> importedModels;
299 + final VoidCallback onImportLocalGguf;
300 + final VoidCallback onImportFromHuggingFace;
301 +
302 + @override
303 + Widget build(BuildContext context) {
304 + final theme = Theme.of(context);
305 + final localImports = importedModels
306 + .where((model) => model.isImportedFromLocalFile)
307 + .length;
308 + final huggingFaceImports = importedModels
309 + .where((model) => model.isImportedFromHuggingFace)
310 + .length;
311 +
312 + return Container(
313 + margin: const EdgeInsets.only(bottom: 16),
314 + padding: const EdgeInsets.all(16),
315 + decoration: BoxDecoration(
316 + color: theme.colorScheme.primary.withValues(alpha: 0.05),
317 + borderRadius: BorderRadius.circular(16),
318 + border: Border.all(
319 + color: theme.colorScheme.primary.withValues(alpha: 0.15),
320 + ),
321 + ),
322 + child: Column(
323 + crossAxisAlignment: CrossAxisAlignment.start,
324 + children: [
325 + Row(
326 + crossAxisAlignment: CrossAxisAlignment.start,
327 + children: [
328 + Container(
329 + padding: const EdgeInsets.all(10),
330 + decoration: BoxDecoration(
331 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
332 + borderRadius: BorderRadius.circular(12),
333 + ),
334 + child: Icon(
335 + Icons.memory_outlined,
336 + color: theme.colorScheme.primary,
337 + ),
338 + ),
339 + const SizedBox(width: 12),
340 + Expanded(
341 + child: Column(
342 + crossAxisAlignment: CrossAxisAlignment.start,
343 + children: [
344 + Text(
345 + 'Bring your own GGUF models',
346 + style: theme.textTheme.titleMedium?.copyWith(
347 + fontWeight: FontWeight.bold,
348 + ),
349 + ),
350 + const SizedBox(height: 4),
351 + Text(
352 + 'Import a .gguf from local storage or download one straight from Hugging Face. Imported models stay on this device and load with llama.cpp.',
353 + style: theme.textTheme.bodySmall?.copyWith(
354 + color: theme.colorScheme.onSurface.withValues(
355 + alpha: 0.7,
356 + ),
357 + ),
358 + ),
359 + ],
360 + ),
361 + ),
362 + ],
363 + ),
364 + const SizedBox(height: 12),
365 + Wrap(
366 + spacing: 8,
367 + runSpacing: 8,
368 + children: [
369 + _SummaryPill(
370 + icon: Icons.layers_outlined,
371 + label: '${importedModels.length} imported',
372 + ),
373 + _SummaryPill(
374 + icon: Icons.folder_open_outlined,
375 + label: '$localImports local files',
376 + ),
377 + _SummaryPill(
378 + icon: Icons.cloud_download_outlined,
379 + label: '$huggingFaceImports Hugging Face',
380 + ),
381 + ],
382 + ),
383 + const SizedBox(height: 12),
384 + Wrap(
385 + spacing: 10,
386 + runSpacing: 10,
387 + children: [
388 + OutlinedButton.icon(
389 + onPressed: onImportLocalGguf,
390 + icon: const Icon(Icons.folder_open_outlined),
391 + label: const Text('Import local GGUF'),
392 + ),
393 + OutlinedButton.icon(
394 + onPressed: onImportFromHuggingFace,
395 + icon: const Icon(Icons.cloud_download_outlined),
396 + label: const Text('Import from Hugging Face'),
397 + ),
398 + ],
399 + ),
400 + ],
401 + ),
402 + );
403 + }
404 +}
405 +
406 +class _SummaryPill extends StatelessWidget {
407 + const _SummaryPill({required this.icon, required this.label});
408 +
409 + final IconData icon;
410 + final String label;
411 +
412 + @override
413 + Widget build(BuildContext context) {
414 + final theme = Theme.of(context);
415 + return Container(
416 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
417 + decoration: BoxDecoration(
418 + color: theme.colorScheme.surface,
419 + borderRadius: BorderRadius.circular(999),
420 + border: Border.all(
421 + color: theme.colorScheme.outline.withValues(alpha: 0.18),
422 + ),
423 + ),
424 + child: Row(
425 + mainAxisSize: MainAxisSize.min,
426 + children: [
427 + Icon(icon, size: 14, color: theme.colorScheme.primary),
428 + const SizedBox(width: 6),
429 + Text(
430 + label,
431 + style: theme.textTheme.labelMedium?.copyWith(
432 + fontWeight: FontWeight.w600,
433 + ),
434 + ),
435 + ],
436 + ),
437 + );
438 + }
439 +}
440 +
441 +class _SectionHeader extends StatelessWidget {
442 + const _SectionHeader({required this.title, required this.subtitle});
443 +
444 + final String title;
445 + final String subtitle;
446 +
447 + @override
448 + Widget build(BuildContext context) {
449 + final theme = Theme.of(context);
450 + return Padding(
451 + padding: const EdgeInsets.only(bottom: 12),
452 + child: Column(
453 + crossAxisAlignment: CrossAxisAlignment.start,
454 + children: [
455 + Text(
456 + title,
457 + style: theme.textTheme.titleMedium?.copyWith(
458 + fontWeight: FontWeight.bold,
459 + ),
460 + ),
461 + const SizedBox(height: 4),
462 + Text(
463 + subtitle,
464 + style: theme.textTheme.bodySmall?.copyWith(
465 + color: theme.colorScheme.onSurface.withValues(alpha: 0.65),
466 + ),
467 + ),
468 + ],
469 + ),
470 + );
471 + }
472 +}
473 +
474 +class _EmptyImportedModelsCard extends StatelessWidget {
475 + const _EmptyImportedModelsCard({
476 + required this.onImportLocalGguf,
477 + required this.onImportFromHuggingFace,
478 + });
479 +
480 + final VoidCallback onImportLocalGguf;
481 + final VoidCallback onImportFromHuggingFace;
482 +
483 + @override
484 + Widget build(BuildContext context) {
485 + final theme = Theme.of(context);
486 + return Container(
487 + margin: const EdgeInsets.only(bottom: 16),
488 + padding: const EdgeInsets.all(18),
489 + decoration: BoxDecoration(
490 + color: theme.colorScheme.surface,
491 + borderRadius: BorderRadius.circular(16),
492 + border: Border.all(
493 + color: theme.colorScheme.outline.withValues(alpha: 0.18),
494 + ),
495 + ),
496 + child: Column(
497 + crossAxisAlignment: CrossAxisAlignment.start,
498 + children: [
499 + Text(
500 + 'No imported GGUF models yet',
501 + style: theme.textTheme.titleSmall?.copyWith(
502 + fontWeight: FontWeight.w700,
503 + ),
504 + ),
505 + const SizedBox(height: 6),
506 + Text(
507 + 'You can bring your own GGUF file from device storage or paste a Hugging Face URL or repo path that points to a .gguf file.',
508 + style: theme.textTheme.bodySmall?.copyWith(
509 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
510 + ),
511 + ),
512 + const SizedBox(height: 12),
513 + Wrap(
514 + spacing: 10,
515 + runSpacing: 10,
516 + children: [
517 + OutlinedButton.icon(
518 + onPressed: onImportLocalGguf,
519 + icon: const Icon(Icons.folder_open_outlined),
520 + label: const Text('Import local GGUF'),
521 + ),
522 + OutlinedButton.icon(
523 + onPressed: onImportFromHuggingFace,
524 + icon: const Icon(Icons.cloud_download_outlined),
525 + label: const Text('Import from Hugging Face'),
526 + ),
527 + ],
528 + ),
529 + ],
530 + ),
531 + );
532 + }
533 +}
534 +
535 +class _HuggingFaceGgufImportDialog extends ConsumerStatefulWidget {
536 + const _HuggingFaceGgufImportDialog();
537 +
538 + @override
539 + ConsumerState<_HuggingFaceGgufImportDialog> createState() =>
540 + _HuggingFaceGgufImportDialogState();
541 +}
542 +
543 +class _HuggingFaceGgufImportDialogState
544 + extends ConsumerState<_HuggingFaceGgufImportDialog> {
545 + static final Uri _huggingFaceModelsUri = Uri.parse(
546 + 'https://huggingface.co/models?search=gguf',
547 + );
548 +
549 + final TextEditingController _urlController = TextEditingController();
550 + CancelToken? _cancelToken;
551 + bool _isImporting = false;
552 + String? _error;
553 + int _receivedBytes = 0;
554 + int _totalBytes = 0;
555 +
556 + @override
557 + void dispose() {
558 + _cancelToken?.cancel();
559 + _urlController.dispose();
560 + super.dispose();
561 + }
562 +
563 + @override
564 + Widget build(BuildContext context) {
565 + final theme = Theme.of(context);
566 + final l10n = AppLocalizations.of(context)!;
567 + final hasHuggingFaceToken = ref.watch(
568 + settingsProvider.select(
569 + (settings) => settings.huggingFaceToken?.trim().isNotEmpty == true,
570 + ),
571 + );
572 + final input = _urlController.text.trim();
573 + final preview = _ImportUrlPreview.fromInput(input);
574 + final progress = _totalBytes > 0 ? _receivedBytes / _totalBytes : null;
575 + final dialogWidth = MediaQuery.of(context).size.width > 560
576 + ? 520.0
577 + : MediaQuery.of(context).size.width * 0.82;
578 +
579 + return AlertDialog(
580 + titlePadding: const EdgeInsets.fromLTRB(24, 22, 24, 0),
581 + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
582 + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
583 + title: Row(
584 + children: [
585 + Container(
586 + padding: const EdgeInsets.all(10),
587 + decoration: BoxDecoration(
588 + color: theme.colorScheme.primary.withValues(alpha: 0.10),
589 + borderRadius: BorderRadius.circular(12),
590 + ),
591 + child: Icon(
592 + Icons.cloud_download_outlined,
593 + color: theme.colorScheme.primary,
594 ),
595 + ),
596 + const SizedBox(width: 12),
597 + const Expanded(child: Text('Import GGUF from Hugging Face')),
598 ],
599 ),
600 + content: SizedBox(
601 + width: dialogWidth,
602 + child: SingleChildScrollView(
603 + child: Column(
604 + mainAxisSize: MainAxisSize.min,
605 + crossAxisAlignment: CrossAxisAlignment.start,
606 + children: [
607 + Text(
608 + 'Paste a direct GGUF URL or a Hugging Face repo path like `owner/repo/blob/main/model.gguf`. Blob links are converted automatically.',
609 + style: theme.textTheme.bodySmall?.copyWith(
610 + color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
611 + ),
612 + ),
613 + const SizedBox(height: 14),
614 + TextField(
615 + controller: _urlController,
616 + enabled: !_isImporting,
617 + maxLines: 2,
618 + minLines: 1,
619 + keyboardType: TextInputType.url,
620 + textInputAction: TextInputAction.done,
621 + onChanged: (_) {
622 + if (_error != null) {
623 + setState(() => _error = null);
624 + } else {
625 + setState(() {});
626 + }
627 + },
628 + onSubmitted: (_) {
629 + if (!_isImporting) {
630 + _startImport();
631 + }
632 + },
633 + decoration: InputDecoration(
634 + labelText: 'GGUF URL or repo path',
635 + hintText:
636 + 'owner/repo/blob/main/model.gguf\nhttps://huggingface.co/owner/repo/resolve/main/model.gguf',
637 + border: const OutlineInputBorder(),
638 + prefixIcon: const Icon(Icons.link_rounded),
639 + ),
640 + ),
641 + const SizedBox(height: 10),
642 + Wrap(
643 + spacing: 8,
644 + runSpacing: 8,
645 + children: [
646 + OutlinedButton.icon(
647 + onPressed: _isImporting ? null : _pasteFromClipboard,
648 + icon: const Icon(Icons.content_paste_rounded, size: 18),
649 + label: const Text('Paste'),
650 + ),
651 + OutlinedButton.icon(
652 + onPressed: _isImporting || input.isEmpty
653 + ? null
654 + : _clearInput,
655 + icon: const Icon(Icons.close_rounded, size: 18),
656 + label: const Text('Clear'),
657 + ),
658 + OutlinedButton.icon(
659 + onPressed: _isImporting
660 + ? null
661 + : () => _openHuggingFace(context),
662 + icon: const Icon(Icons.open_in_new_rounded, size: 18),
663 + label: const Text('Browse GGUFs'),
664 + ),
665 + ],
666 + ),
667 + if (preview != null) ...[
668 + const SizedBox(height: 14),
669 + _ImportPreviewCard(preview: preview),
670 + ],
671 + const SizedBox(height: 12),
672 + Container(
673 + width: double.infinity,
674 + padding: const EdgeInsets.all(12),
675 + decoration: BoxDecoration(
676 + color: theme.colorScheme.surface,
677 + borderRadius: BorderRadius.circular(14),
678 + border: Border.all(
679 + color: theme.colorScheme.outline.withValues(alpha: 0.18),
680 + ),
681 + ),
682 + child: Row(
683 + crossAxisAlignment: CrossAxisAlignment.start,
684 + children: [
685 + Icon(
686 + hasHuggingFaceToken
687 + ? Icons.verified_user_outlined
688 + : Icons.info_outline,
689 + size: 18,
690 + color: hasHuggingFaceToken
691 + ? Colors.green
692 + : theme.colorScheme.primary,
693 + ),
694 + const SizedBox(width: 8),
695 + Expanded(
696 + child: Column(
697 + crossAxisAlignment: CrossAxisAlignment.start,
698 + children: [
699 + Text(
700 + hasHuggingFaceToken
701 + ? 'Hugging Face token ready'
702 + : 'Token optional but recommended',
703 + style: theme.textTheme.labelLarge?.copyWith(
704 + fontWeight: FontWeight.w700,
705 + ),
706 + ),
707 + const SizedBox(height: 4),
708 + Text(
709 + hasHuggingFaceToken
710 + ? 'Your saved token will be used automatically for gated or private repositories.'
711 + : '${l10n.model_requires_huggingface_token} Add one in Settings if this GGUF is gated or private.',
712 + style: theme.textTheme.bodySmall,
713 + ),
714 + ],
715 + ),
716 + ),
717 + ],
718 + ),
719 + ),
720 + if (_isImporting) ...[
721 + const SizedBox(height: 16),
722 + Container(
723 + width: double.infinity,
724 + padding: const EdgeInsets.all(14),
725 + decoration: BoxDecoration(
726 + color: theme.colorScheme.primary.withValues(alpha: 0.06),
727 + borderRadius: BorderRadius.circular(16),
728 + border: Border.all(
729 + color: theme.colorScheme.primary.withValues(alpha: 0.14),
730 + ),
731 + ),
732 + child: Column(
733 + crossAxisAlignment: CrossAxisAlignment.start,
734 + children: [
735 + Row(
736 + children: [
737 + Expanded(
738 + child: Text(
739 + preview?.fileName ?? 'Downloading GGUF',
740 + maxLines: 1,
741 + overflow: TextOverflow.ellipsis,
742 + style: theme.textTheme.labelLarge?.copyWith(
743 + fontWeight: FontWeight.w700,
744 + ),
745 + ),
746 + ),
747 + const SizedBox(width: 8),
748 + Container(
749 + padding: const EdgeInsets.symmetric(
750 + horizontal: 10,
751 + vertical: 5,
752 + ),
753 + decoration: BoxDecoration(
754 + color: theme.colorScheme.primary.withValues(
755 + alpha: 0.12,
756 + ),
757 + borderRadius: BorderRadius.circular(999),
758 + ),
759 + child: Text(
760 + progress == null
761 + ? 'Preparing'
762 + : '${(progress * 100).toStringAsFixed(1)}%',
763 + style: theme.textTheme.labelMedium?.copyWith(
764 + color: theme.colorScheme.primary,
765 + fontWeight: FontWeight.w700,
766 + ),
767 + ),
768 + ),
769 + ],
770 + ),
771 + const SizedBox(height: 12),
772 + _ImportProgressBar(progress: progress),
773 + const SizedBox(height: 10),
774 + Text(
775 + progress != null
776 + ? '${_formatBytes(_receivedBytes)} / ${_formatBytes(_totalBytes)}'
777 + : 'Preparing download...',
778 + style: theme.textTheme.bodySmall?.copyWith(
779 + color: theme.colorScheme.onSurface.withValues(
780 + alpha: 0.70,
781 + ),
782 + fontWeight: FontWeight.w600,
783 + ),
784 + ),
785 + ],
786 + ),
787 + ),
788 + ],
789 + if (_error != null) ...[
790 + const SizedBox(height: 12),
791 + Container(
792 + width: double.infinity,
793 + padding: const EdgeInsets.all(12),
794 + decoration: BoxDecoration(
795 + color: Colors.red.withValues(alpha: 0.08),
796 + borderRadius: BorderRadius.circular(14),
797 + border: Border.all(
798 + color: Colors.red.withValues(alpha: 0.18),
799 + ),
800 + ),
801 + child: Row(
802 + crossAxisAlignment: CrossAxisAlignment.start,
803 + children: [
804 + const Icon(
805 + Icons.error_outline_rounded,
806 + color: Colors.red,
807 + size: 18,
808 + ),
809 + const SizedBox(width: 8),
810 + Expanded(
811 + child: Text(
812 + _error!,
813 + style: theme.textTheme.bodySmall?.copyWith(
814 + color: Colors.red.shade700,
815 + fontWeight: FontWeight.w600,
816 + ),
817 + ),
818 + ),
819 + ],
820 + ),
821 + ),
822 + ],
823 + ],
824 + ),
825 + ),
826 + ),
827 + actions: [
828 + TextButton(
829 + onPressed: _isImporting
830 + ? () {
831 + _cancelToken?.cancel();
832 + Navigator.of(context).pop();
833 + }
834 + : () => Navigator.of(context).pop(),
835 + child: Text(_isImporting ? 'Cancel import' : l10n.cancel),
836 + ),
837 + ElevatedButton.icon(
838 + onPressed: _isImporting || input.isEmpty ? null : _startImport,
839 + icon: const Icon(Icons.download_rounded, size: 18),
840 + label: const Text('Import GGUF'),
841 + ),
842 + ],
843 + );
844 + }
845 +
846 + Future<void> _pasteFromClipboard() async {
847 + final data = await Clipboard.getData('text/plain');
848 + final text = data?.text?.trim();
849 + if (!mounted) return;
850 + if (text == null || text.isEmpty) {
851 + ScaffoldMessenger.of(
852 + context,
853 + ).showSnackBar(const SnackBar(content: Text('Clipboard is empty.')));
854 + return;
855 + }
856 + setState(() {
857 + _urlController.text = text;
858 + _error = null;
859 + });
860 + }
861 +
862 + void _clearInput() {
863 + setState(() {
864 + _urlController.clear();
865 + _error = null;
866 + });
867 + }
868 +
869 + Future<void> _openHuggingFace(BuildContext context) async {
870 + final messenger = ScaffoldMessenger.of(context);
871 + final launched = await launchUrl(
872 + _huggingFaceModelsUri,
873 + mode: LaunchMode.externalApplication,
874 + );
875 + if (!launched && context.mounted) {
876 + messenger.showSnackBar(
877 + const SnackBar(content: Text('Could not open Hugging Face.')),
878 + );
879 + }
880 + }
881 +
882 + Future<void> _startImport() async {
883 + final sourceUrl = _urlController.text.trim();
884 + if (sourceUrl.isEmpty) {
885 + setState(() {
886 + _error = 'Paste a Hugging Face GGUF URL or repo path.';
887 + });
888 + return;
889 + }
890 +
891 + setState(() {
892 + _isImporting = true;
893 + _error = null;
894 + _receivedBytes = 0;
895 + _totalBytes = 0;
896 + });
897 +
898 + _cancelToken = CancelToken();
899 +
900 + try {
901 + final huggingFaceToken = ref.read(
902 + settingsProvider.select((settings) => settings.huggingFaceToken),
903 + );
904 + final model = await ref
905 + .read(importedGgufModelsProvider.notifier)
906 + .importModelFromHuggingFaceUrl(
907 + sourceUrl,
908 + huggingFaceToken: huggingFaceToken,
909 + cancelToken: _cancelToken,
910 + onProgress: (receivedBytes, totalBytes) {
911 + if (!mounted) return;
912 + setState(() {
913 + _receivedBytes = receivedBytes;
914 + _totalBytes = totalBytes > 0 ? totalBytes : 0;
915 + });
916 + },
917 + );
918 +
919 + if (!mounted) return;
920 + Navigator.of(context).pop(model);
921 + } catch (e) {
922 + if (!mounted) return;
923 + setState(() {
924 + _isImporting = false;
925 + _error = _friendlyError(e);
926 + });
927 + }
928 + }
929 +
930 + String _friendlyError(Object error) {
931 + final message = error.toString();
932 + if (message.startsWith('HttpException: ')) {
933 + return message.substring('HttpException: '.length);
934 + }
935 + if (message.startsWith('FormatException: ')) {
936 + return message.substring('FormatException: '.length);
937 + }
938 + if (message.startsWith('Exception: ')) {
939 + return message.substring('Exception: '.length);
940 + }
941 + return message;
942 + }
943 +
944 + String _formatBytes(int bytes) {
945 + if (bytes <= 0) return '0 B';
946 + final kb = bytes / 1024;
947 + if (kb < 1024) return '${kb.toStringAsFixed(0)} KB';
948 + final mb = kb / 1024;
949 + if (mb < 1024) return '${mb.toStringAsFixed(1)} MB';
950 + final gb = mb / 1024;
951 + return '${gb.toStringAsFixed(2)} GB';
952 + }
953 +}
954 +
955 +class _ImportPreviewCard extends StatelessWidget {
956 + const _ImportPreviewCard({required this.preview});
957 +
958 + final _ImportUrlPreview preview;
959 +
960 + @override
961 + Widget build(BuildContext context) {
962 + final theme = Theme.of(context);
963 +
964 + return Container(
965 + width: double.infinity,
966 + padding: const EdgeInsets.all(12),
967 + decoration: BoxDecoration(
968 + color: theme.colorScheme.surfaceContainerHighest.withValues(
969 + alpha: 0.35,
970 + ),
971 + borderRadius: BorderRadius.circular(14),
972 + border: Border.all(
973 + color: theme.colorScheme.outline.withValues(alpha: 0.18),
974 + ),
975 + ),
976 + child: Column(
977 + crossAxisAlignment: CrossAxisAlignment.start,
978 + children: [
979 + Wrap(
980 + spacing: 8,
981 + runSpacing: 8,
982 + children: [
983 + _PreviewPill(
984 + icon: preview.isBlobLink
985 + ? Icons.sync_alt_rounded
986 + : Icons.link_rounded,
987 + label: preview.isBlobLink
988 + ? 'Blob link'
989 + : preview.inputStyleLabel,
990 + ),
991 + if (preview.fileName != null)
992 + _PreviewPill(
993 + icon: Icons.insert_drive_file_outlined,
994 + label: preview.fileName!,
995 + ),
996 + ],
997 + ),
998 + const SizedBox(height: 10),
999 + if (preview.repoPath != null)
1000 + _PreviewRow(label: 'Repository', value: preview.repoPath!),
1001 + if (preview.normalizedDisplay != null)
1002 + _PreviewRow(
1003 + label: 'Detected path',
1004 + value: preview.normalizedDisplay!,
1005 + ),
1006 + ],
1007 + ),
1008 + );
1009 + }
1010 +}
1011 +
1012 +class _PreviewPill extends StatelessWidget {
1013 + const _PreviewPill({required this.icon, required this.label});
1014 +
1015 + final IconData icon;
1016 + final String label;
1017 +
1018 + @override
1019 + Widget build(BuildContext context) {
1020 + final theme = Theme.of(context);
1021 + return Container(
1022 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1023 + decoration: BoxDecoration(
1024 + color: theme.colorScheme.primary.withValues(alpha: 0.08),
1025 + borderRadius: BorderRadius.circular(999),
1026 + ),
1027 + child: Row(
1028 + mainAxisSize: MainAxisSize.min,
1029 + children: [
1030 + Icon(icon, size: 14, color: theme.colorScheme.primary),
1031 + const SizedBox(width: 6),
1032 + Text(
1033 + label,
1034 + style: theme.textTheme.labelMedium?.copyWith(
1035 + color: theme.colorScheme.primary,
1036 + fontWeight: FontWeight.w700,
1037 + ),
1038 + ),
1039 + ],
1040 + ),
1041 + );
1042 + }
1043 +}
1044 +
1045 +class _PreviewRow extends StatelessWidget {
1046 + const _PreviewRow({required this.label, required this.value});
1047 +
1048 + final String label;
1049 + final String value;
1050 +
1051 + @override
1052 + Widget build(BuildContext context) {
1053 + final theme = Theme.of(context);
1054 + return Padding(
1055 + padding: const EdgeInsets.only(top: 4),
1056 + child: Column(
1057 + crossAxisAlignment: CrossAxisAlignment.start,
1058 + children: [
1059 + Text(
1060 + label,
1061 + style: theme.textTheme.labelSmall?.copyWith(
1062 + color: theme.colorScheme.onSurface.withValues(alpha: 0.62),
1063 + fontWeight: FontWeight.w700,
1064 + ),
1065 + ),
1066 + const SizedBox(height: 2),
1067 + Text(
1068 + value,
1069 + style: theme.textTheme.bodySmall?.copyWith(
1070 + fontWeight: FontWeight.w600,
1071 + ),
1072 + ),
1073 + ],
1074 + ),
1075 + );
1076 + }
1077 +}
1078 +
1079 +class _ImportProgressBar extends StatelessWidget {
1080 + const _ImportProgressBar({required this.progress});
1081 +
1082 + final double? progress;
1083 +
1084 + @override
1085 + Widget build(BuildContext context) {
1086 + final theme = Theme.of(context);
1087 + final trackColor = theme.colorScheme.primary.withValues(alpha: 0.12);
1088 + final fillColor = theme.colorScheme.primary;
1089 + final normalizedProgress = progress?.clamp(0.0, 1.0);
1090 +
1091 + return ClipRRect(
1092 + borderRadius: BorderRadius.circular(999),
1093 + child: SizedBox(
1094 + height: 10,
1095 + child: LinearProgressIndicator(
1096 + value: normalizedProgress?.toDouble(),
1097 + backgroundColor: trackColor,
1098 + valueColor: AlwaysStoppedAnimation<Color>(fillColor),
1099 + ),
1100 + ),
1101 + );
1102 + }
1103 +}
1104 +
1105 +class _ImportUrlPreview {
1106 + const _ImportUrlPreview({
1107 + required this.inputStyleLabel,
1108 + required this.isBlobLink,
1109 + this.repoPath,
1110 + this.fileName,
1111 + this.normalizedDisplay,
1112 + });
1113 +
1114 + final String inputStyleLabel;
1115 + final bool isBlobLink;
1116 + final String? repoPath;
1117 + final String? fileName;
1118 + final String? normalizedDisplay;
1119 +
1120 + static _ImportUrlPreview? fromInput(String input) {
1121 + final trimmed = input.trim();
1122 + if (trimmed.isEmpty) return null;
1123 +
1124 + final hasScheme =
1125 + trimmed.startsWith('https://') || trimmed.startsWith('http://');
1126 + final displayUri = hasScheme ? Uri.tryParse(trimmed) : null;
1127 + final pathSource = displayUri != null && displayUri.pathSegments.isNotEmpty
1128 + ? displayUri.pathSegments
1129 + : trimmed.split('/').where((part) => part.isNotEmpty).toList();
1130 +
1131 + if (pathSource.isEmpty) {
1132 + return const _ImportUrlPreview(
1133 + inputStyleLabel: 'Manual path',
1134 + isBlobLink: false,
1135 + );
1136 + }
1137 +
1138 + final blobIndex = pathSource.indexOf('blob');
1139 + final resolveIndex = pathSource.indexOf('resolve');
1140 + final fileName =
1141 + pathSource.isNotEmpty && pathSource.last.toLowerCase().endsWith('.gguf')
1142 + ? pathSource.last
1143 + : null;
1144 + final repoPath = pathSource.length >= 2
1145 + ? '${pathSource[0]}/${pathSource[1]}'
1146 + : null;
1147 +
1148 + String? normalizedDisplay;
1149 + if (blobIndex != -1) {
1150 + final normalized = [...pathSource]..[blobIndex] = 'resolve';
1151 + normalizedDisplay = normalized.join('/');
1152 + } else if (resolveIndex != -1 || fileName != null) {
1153 + normalizedDisplay = pathSource.join('/');
1154 + }
1155 +
1156 + return _ImportUrlPreview(
1157 + inputStyleLabel: hasScheme ? 'Direct URL' : 'Repo path',
1158 + isBlobLink: blobIndex != -1,
1159 + repoPath: repoPath,
1160 + fileName: fileName,
1161 + normalizedDisplay: normalizedDisplay,
1162 );
1163 }
1164 }
@@ -182,11 +1182,9 @@ class _EngineStatusCard extends StatelessWidget {
1182 final loadedId = engineState.loadedModelId;
1183 final loadedName = loadedId == null
1184 ? 'Unknown'
185 - : models
186 - .where((m) => m.id == loadedId)
187 - .map((m) => m.name)
188 - .followedBy([loadedId])
189 - .first;
1185 + : models.where((m) => m.id == loadedId).map((m) => m.name).followedBy(
1186 + [loadedId],
1187 + ).first;
1188
1189 return Container(
1190 padding: const EdgeInsets.all(12),
@@ -202,7 +1200,10 @@ class _EngineStatusCard extends StatelessWidget {
1200 const SizedBox(width: 8),
1201 Expanded(
1202 child: Text(
205 - l10n.model_loaded(loadedName, engineState.backend?.name ?? 'CPU'),
1203 + l10n.model_loaded(
1204 + loadedName,
1205 + engineState.backend?.name ?? 'CPU',
1206 + ),
1207 style: const TextStyle(color: Colors.green),
1208 ),
1209 ),
pubspec.yaml
+1 -1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.4.1+45
19 +version: 1.4.2+48
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"
test/features/on_device/imported_gguf_model_repository_test.dart
+46 -1
@@ -1,6 +1,7 @@
1 import 'dart:convert';
2 import 'dart:io';
3
4 +import 'package:dio/dio.dart';
5 import 'package:flutter_test/flutter_test.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';
@@ -14,7 +15,7 @@ void main() {
15 }) async {
16 SharedPreferences.setMockInitialValues(initialValues);
17 final prefs = await SharedPreferences.getInstance();
17 - return ImportedGgufModelRepository(prefs);
18 + return ImportedGgufModelRepository(prefs, Dio());
19 }
20
21 group('ImportedGgufModelMetadata', () {
@@ -26,6 +27,7 @@ void main() {
27 filePath: '/tmp/Tiny-Llama.Q4.gguf',
28 fileSizeBytes: 1234,
29 importedAt: importedAt,
30 + source: OnDeviceImportedSource.localFile,
31 );
32
33 final restored = ImportedGgufModelMetadata.fromJson(
@@ -43,8 +45,28 @@ void main() {
45 expect(model.localPath, metadata.filePath);
46 expect(model.fileName, 'Tiny-Llama.Q4.gguf');
47 expect(model.isImported, isTrue);
48 + expect(model.importedSource, OnDeviceImportedSource.localFile);
49 expect(model.isLlamaCpp, isTrue);
50 });
51 +
52 + test('estimates imported GGUF RAM requirement from file size', () {
53 + final metadata = ImportedGgufModelMetadata(
54 + id: 'gguf-large',
55 + name: 'Large GGUF',
56 + filePath: '/tmp/Large.gguf',
57 + fileSizeBytes: 6 * 1024 * 1024 * 1024,
58 + importedAt: DateTime.utc(2026, 6, 21, 12),
59 + source: OnDeviceImportedSource.huggingFace,
60 + sourceUrl:
61 + 'https://huggingface.co/example/repo/resolve/main/Large.gguf',
62 + );
63 +
64 + final model = metadata.toOnDeviceModel();
65 +
66 + expect(model.minRamMb, greaterThan(2048));
67 + expect(model.importedSource, OnDeviceImportedSource.huggingFace);
68 + expect(model.huggingFaceUrl, metadata.sourceUrl);
69 + });
70 });
71
72 group('ImportedGgufModelRepository', () {
@@ -117,6 +139,28 @@ void main() {
139 expect(existing.map((model) => model.id), ['gguf-kept']);
140 expect(repository.load().map((model) => model.id), ['gguf-kept']);
141 });
142 +
143 + test('rejects non-Hugging Face GGUF URLs before downloading', () async {
144 + final repository = await createRepository();
145 +
146 + expect(
147 + () => repository.importFromHuggingFaceUrl(
148 + 'https://huggingface.co.evil.example/org/repo/resolve/main/model.gguf',
149 + ),
150 + throwsA(isA<FormatException>()),
151 + );
152 + });
153 +
154 + test('requires HTTPS for Hugging Face GGUF imports', () async {
155 + final repository = await createRepository();
156 +
157 + expect(
158 + () => repository.importFromHuggingFaceUrl(
159 + 'http://huggingface.co/org/repo/resolve/main/model.gguf',
160 + ),
161 + throwsA(isA<FormatException>()),
162 + );
163 + });
164 });
165 }
166
@@ -130,5 +174,6 @@ ImportedGgufModelMetadata _metadata({
174 filePath: filePath,
175 fileSizeBytes: 42,
176 importedAt: DateTime.utc(2026, 6, 21),
177 + source: OnDeviceImportedSource.localFile,
178 );
179 }
test/features/on_device/model_manager_gguf_widget_test.dart
+30 -19
@@ -1,5 +1,3 @@
1 -import 'dart:convert';
2 -
1 import 'package:flutter/foundation.dart';
2 import 'package:flutter/material.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,7 +6,7 @@ import 'package:localmind/core/device/device_memory_service.dart';
6 import 'package:localmind/core/providers/device_info_providers.dart';
7 import 'package:localmind/core/providers/storage_providers.dart';
8 import 'package:localmind/core/theme/app_theme.dart';
11 -import 'package:localmind/features/on_device/data/imported_gguf_model_repository.dart';
9 +import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10 import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
11 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
12 import 'package:localmind/features/on_device/views/model_manager_screen.dart';
@@ -17,23 +15,33 @@ import 'package:shared_preferences/shared_preferences.dart';
15 import 'package:shadcn_ui/shadcn_ui.dart';
16
17 void main() {
20 - const storageKey = 'imported_gguf_models_v1';
21 -
18 testWidgets('model manager shows Import GGUF and imported model actions', (
19 tester,
20 ) async {
21 debugDefaultTargetPlatformOverride = TargetPlatform.android;
22 try {
27 - final metadata = ImportedGgufModelMetadata(
23 + final importedModel = OnDeviceModel(
24 id: 'gguf-widget',
25 name: 'Widget GGUF',
30 - filePath: '/tmp/widget.gguf',
26 + huggingFaceUrl: '',
27 fileSizeBytes: 4096,
28 + license: 'Local file',
29 + description: 'Imported GGUF model for local llama.cpp inference.',
30 + minRamMb: 2048,
31 + parameterLabel: 'GGUF',
32 + bestFor: 'Local GGUF inference',
33 + languagesLabel: 'Local',
34 + backendNote: 'llama.cpp',
35 + isCpuOnly: true,
36 + runtime: OnDeviceModelRuntime.llamaCpp,
37 + format: OnDeviceModelFormat.gguf,
38 + localPath: '/tmp/widget.gguf',
39 importedAt: DateTime.utc(2026, 6, 21),
40 + isImported: true,
41 + importedSource: OnDeviceImportedSource.localFile,
42 );
34 - SharedPreferences.setMockInitialValues({
35 - storageKey: json.encode([metadata.toJson()]),
36 - });
43 +
44 + SharedPreferences.setMockInitialValues({});
45 final prefs = await SharedPreferences.getInstance();
46
47 await tester.pumpWidget(
@@ -43,6 +51,12 @@ void main() {
51 onDeviceGemmaServiceProvider.overrideWithValue(
52 _FakeOnDeviceGemmaService(),
53 ),
54 + onDeviceModelsProvider.overrideWith(
55 + (ref) => [...OnDeviceModel.curatedModels, importedModel],
56 + ),
57 + downloadedModelsProvider.overrideWith(
58 + (ref) async => {'gguf-widget'},
59 + ),
60 deviceMemoryProvider.overrideWith(
61 (ref) async => const DeviceMemoryInfo(
62 totalMemoryMb: 8192,
@@ -61,18 +75,15 @@ void main() {
75 ),
76 ),
77 );
64 - await tester.pumpAndSettle();
78 + await tester.pump();
79 + await tester.pump(const Duration(milliseconds: 700));
80
81 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();
82 + expect(find.text('Imported GGUF models'), findsOneWidget);
83
74 - expect(find.text('Widget GGUF'), findsOneWidget);
75 - expect(find.text('Imported'), findsWidgets);
84 + await tester.scrollUntilVisible(find.text('Widget GGUF'), 400);
85 + await tester.pump();
86 + expect(find.text('Local file'), findsWidgets);
87 expect(find.text('GGUF'), findsWidgets);
88 expect(find.text('llama.cpp'), findsWidgets);
89 expect(find.text('Load'), findsWidgets);
test/features/on_device/on_device_imported_models_provider_test.dart
+3
@@ -21,6 +21,7 @@ void main() {
21 filePath: '/tmp/imported.gguf',
22 fileSizeBytes: 4096,
23 importedAt: DateTime.utc(2026, 6, 21),
24 + source: OnDeviceImportedSource.localFile,
25 );
26 SharedPreferences.setMockInitialValues({
27 storageKey: json.encode([metadata.toJson()]),
@@ -39,6 +40,7 @@ void main() {
40 expect(imported.format, OnDeviceModelFormat.gguf);
41 expect(imported.localPath, metadata.filePath);
42 expect(imported.isImported, isTrue);
43 + expect(imported.importedSource, OnDeviceImportedSource.localFile);
44 },
45 );
46
@@ -51,6 +53,7 @@ void main() {
53 filePath: '/tmp/downloaded.gguf',
54 fileSizeBytes: 4096,
55 importedAt: DateTime.utc(2026, 6, 21),
56 + source: OnDeviceImportedSource.localFile,
57 );
58 SharedPreferences.setMockInitialValues({
59 storageKey: json.encode([metadata.toJson()]),