Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
options as `loadModelSource(...)`, while preserving the existing
`loadMultimodalProjector(...)` path/string API.

* Improved the runnable chat app's Manage Models cache UX so model and mmproj
asset cache states are shown separately, missing multimodal projectors can be
re-cached without re-fetching already cached model assets, and runtime media
capability mismatches surface as user-readable warnings. Custom signed or
tokenized Hugging Face URLs now require confirmation before they are saved.

## 0.8.12

* Updated the default LiteRT-LM native runtime pin to
Expand Down
150 changes: 147 additions & 3 deletions example/chat_app/lib/screens/manage_models_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>

final Map<String, ValueNotifier<_ModelDownloadUiState>>
_downloadUiStateByFile = {};
final Map<String, ModelProfileCacheState> _cacheStateByFile = {};
final Map<String, int> _lastDownloadedBytes = {};
final Map<String, DateTime> _lastDownloadSampleAt = {};
final Map<String, double> _smoothedDownloadRateBytesPerSec = {};
Expand Down Expand Up @@ -97,11 +98,19 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
await _loadCustomModels();
_modelsDir = await _modelService.getModelsDirectory();
_downloadedFiles = await _modelService.getDownloadedModels(_models);
await _refreshCacheStates(_models);
if (mounted) {
setState(() {});
}
}

Future<void> _refreshCacheStates(Iterable<DownloadableModel> models) async {
for (final model in models) {
_cacheStateByFile[model.filename] = await _modelService
.getModelCacheState(model);
}
}

Future<void> _loadCustomModels() async {
final prefs = await SharedPreferences.getInstance();
final entries = prefs.getStringList(_customModelsPrefsKey) ?? const [];
Expand Down Expand Up @@ -233,6 +242,7 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
});

_downloadedFiles = await _modelService.getDownloadedModels(_models);
await _refreshCacheStates(<DownloadableModel>[model]);
if (mounted) {
setState(() {});
}
Expand All @@ -255,6 +265,81 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
return null;
}

List<String> _credentialLikeCustomUrlLabels({
required String modelUrl,
required String? mmprojUrl,
}) {
return <String>[
if (_hasCredentialLikePersistentUrlParts(modelUrl)) 'GGUF URL',
if (mmprojUrl != null &&
mmprojUrl.isNotEmpty &&
_hasCredentialLikePersistentUrlParts(mmprojUrl))
'MMProj URL',
];
}

bool _hasCredentialLikePersistentUrlParts(String value) {
final uri = Uri.tryParse(value.trim());
if (uri == null) {
return false;
}
if (uri.userInfo.isNotEmpty || uri.fragment.isNotEmpty) {
return true;
}

const benignQueryKeys = {'download'};
return uri.queryParameters.keys.any((key) {
final lower = key.toLowerCase();
if (benignQueryKeys.contains(lower)) {
return false;
}
return lower.contains('token') ||
lower.contains('sig') ||
lower.contains('signature') ||
lower.contains('expires') ||
lower.contains('credential') ||
lower.contains('key') ||
lower.contains('secret') ||
lower.contains('auth') ||
lower.contains('session') ||
lower.startsWith('x-amz');
});
}

Future<bool> _confirmSavingCredentialLikeCustomUrls(
BuildContext context,
List<String> labels,
) async {
final labelText = labels.length == 1
? labels.single
: '${labels.take(labels.length - 1).join(', ')} and ${labels.last}';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Save credentialed URL?'),
content: Text(
'$labelText includes user info, a fragment, or credential-like '
'query parameters. Custom model URLs are saved in local '
'preferences. Prefer a public ?download=true URL or runtime '
'headers for private access.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Review URL'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Save anyway'),
),
],
);
},
);
return confirmed ?? false;
}

Future<void> _showAddHuggingFaceDialog() async {
final nameController = TextEditingController();
final modelUrlController = TextEditingController();
Expand Down Expand Up @@ -376,6 +461,22 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
return;
}

final credentialUrlLabels = _credentialLikeCustomUrlLabels(
modelUrl: url,
mmprojUrl: mmprojUrl.isEmpty ? null : mmprojUrl,
);
if (credentialUrlLabels.isNotEmpty) {
if (!dialogContext.mounted) return;
final confirmed =
await _confirmSavingCredentialLikeCustomUrls(
dialogContext,
credentialUrlLabels,
);
if (!confirmed) {
return;
}
}

await _addCustomModelEntry(customModel);
if (!dialogContext.mounted) return;
Navigator.of(dialogContext).pop();
Expand Down Expand Up @@ -739,8 +840,13 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
clearTask: true,
);
_clearDownloadTracking(model.filename);
final downloadedFiles = await _modelService.getDownloadedModels(_models);
await _refreshCacheStates(_models);
if (!mounted) {
return;
}
setState(() {
_downloadedFiles.add(model.filename);
_downloadedFiles = downloadedFiles;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${model.name} downloaded successfully.')),
Expand All @@ -764,6 +870,15 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
_clearDownloadTracking(model.filename);
}

final downloadedFiles = await _modelService.getDownloadedModels(_models);
await _refreshCacheStates(_models);
if (!mounted) {
return;
}
setState(() {
_downloadedFiles = downloadedFiles;
});

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
Expand Down Expand Up @@ -837,8 +952,13 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
});

if (provider.error == null) {
final capabilityWarning = _runtimeCapabilityWarning(model, provider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${model.name} loaded successfully.')),
SnackBar(
content: Text(
capabilityWarning ?? '${model.name} loaded successfully.',
),
),
);
widget.onModelActivated?.call();
}
Expand All @@ -862,9 +982,12 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
);
_clearDownloadTracking(model.filename);
await _disposeDownloadController(model.filename);
final downloadedFiles = await _modelService.getDownloadedModels(_models);
await _refreshCacheStates(_models);
if (!mounted) return;

setState(() {
_downloadedFiles.remove(model.filename);
_downloadedFiles = downloadedFiles;
});
}

Expand Down Expand Up @@ -932,6 +1055,8 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
}
_downloadControllers.clear();
_downloadedFiles = await _modelService.getDownloadedModels(_models);
_cacheStateByFile.clear();
await _refreshCacheStates(_models);

await _saveCustomModels();

Expand Down Expand Up @@ -1060,6 +1185,24 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
: null;
}

String? _runtimeCapabilityWarning(
DownloadableModel model,
ChatProvider provider,
) {
final missing = <String>[
if (model.supportsVision && !provider.supportsVision) 'vision',
if (model.supportsAudio && !provider.supportsAudio) 'audio',
];
if (missing.isEmpty) {
return null;
}

final capabilityLabel = missing.length == 1
? missing.single
: '${missing.take(missing.length - 1).join(', ')} and ${missing.last}';
return 'Loaded, but the active runtime/projector did not report $capabilityLabel support. Media controls stay disabled for unsupported inputs.';
}

Future<void> _setSelectedModelMmprojMode(
ChatProvider provider,
DownloadableModel model, {
Expand Down Expand Up @@ -1339,6 +1482,7 @@ class _ManageModelsScreenState extends State<ManageModelsScreen>
isDownloaded: _downloadedFiles.contains(
model.filename,
),
cacheState: _cacheStateByFile[model.filename],
isDownloading: downloadState.isDownloading,
progress: downloadState.progress,
downloadStatusLabel: detail == null
Expand Down
Loading
Loading