1
This commit is contained in:
@@ -76,11 +76,34 @@ class ModelManager extends ChangeNotifier {
|
||||
final Future<Directory> Function()? _rootDirOverride;
|
||||
|
||||
List<ModelBundle> _models = const [];
|
||||
List<ModelCatalogItem> _catalog = const [];
|
||||
final Set<int> _activeIds = {};
|
||||
final Set<int> _downloadedIds = {};
|
||||
final Map<int, double> _progress = {};
|
||||
final Map<int, String> _errors = {};
|
||||
bool _activeLoaded = false;
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
String? _error;
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// 服务器目录(弹层模型清单展示用)
|
||||
List<ModelCatalogItem> get catalog => _catalog;
|
||||
|
||||
/// 激活模型 id 集合(多选叠加)
|
||||
Set<int> get activeDatasetIds => Set.unmodifiable(_activeIds);
|
||||
|
||||
bool isActive(int datasetId) => _activeIds.contains(datasetId);
|
||||
|
||||
/// 该数据集模型文件是否已下载到本地(同步判断,内存态)
|
||||
bool isDownloaded(int datasetId) => _downloadedIds.contains(datasetId);
|
||||
|
||||
/// 下载进度 0..1(无下载/已完成为 null)
|
||||
double? progressOf(int datasetId) => _progress[datasetId];
|
||||
|
||||
/// 下载失败原因(失败后可重试)
|
||||
String? errorOf(int datasetId) => _errors[datasetId];
|
||||
|
||||
ModelManager._({String? baseUrl, http.Client? client})
|
||||
: this(baseUrl: baseUrl, client: client);
|
||||
|
||||
@@ -94,7 +117,7 @@ class ModelManager extends ChangeNotifier {
|
||||
_client = client ?? http.Client(),
|
||||
_rootDirOverride = rootDir;
|
||||
|
||||
/// 已就绪模型列表(空 = 未下载任何模型,相机页仅预览)
|
||||
/// 已激活且已下载的模型列表(空 = 未下载任何模型,相机页仅预览)
|
||||
List<ModelBundle> get models => _models;
|
||||
|
||||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||||
@@ -125,6 +148,7 @@ class ModelManager extends ChangeNotifier {
|
||||
|
||||
Future<void> _doRefresh() async {
|
||||
try {
|
||||
await _loadActive();
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
@@ -133,65 +157,108 @@ class ModelManager extends ChangeNotifier {
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
final catalog = list
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final failed = <String>[];
|
||||
for (final item in catalog) {
|
||||
if (!await _ensureLocal(item)) failed.add(item.datasetName);
|
||||
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
|
||||
final downloaded = <int>{};
|
||||
for (final item in _catalog) {
|
||||
if (await _isLocal(item)) downloaded.add(item.datasetId);
|
||||
}
|
||||
await _prune(catalog);
|
||||
_models = await _loadBundles(catalog);
|
||||
_downloadedIds
|
||||
..clear()
|
||||
..addAll(downloaded);
|
||||
|
||||
await _prune(_catalog);
|
||||
// 服务器已下线的数据集移出激活集
|
||||
final catalogIds = _catalog.map((c) => c.datasetId).toSet();
|
||||
if (_activeIds.any((id) => !catalogIds.contains(id))) {
|
||||
_activeIds.removeWhere((id) => !catalogIds.contains(id));
|
||||
await _saveActive();
|
||||
}
|
||||
|
||||
_models = await _loadBundles(_catalog);
|
||||
_ready = true;
|
||||
_error = failed.isEmpty
|
||||
? null
|
||||
: '模型下载失败:${failed.join(',')}(重试旧模型或稍后再试)';
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
// 已就绪过则保留旧模型,不覆盖 error(下载级错误优先展示)
|
||||
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保证目录条目在本地可用:meta 匹配且文件在 → 跳过;否则下载并校验 sha256。
|
||||
Future<bool> _ensureLocal(ModelCatalogItem item) async {
|
||||
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
|
||||
Future<bool> _isLocal(ModelCatalogItem item) async {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
try {
|
||||
final meta = await _readMeta(dir);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
if (meta != null &&
|
||||
return meta != null &&
|
||||
meta['version'] == item.version &&
|
||||
meta['sha256'] == item.sha256 &&
|
||||
await file.exists()) {
|
||||
return true;
|
||||
}
|
||||
// 版本更新或文件缺失:下载校验(失败重试一次)
|
||||
await file.exists();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta);
|
||||
/// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。
|
||||
Future<bool> downloadModel(ModelCatalogItem item,
|
||||
{void Function(int received, int total)? onProgress}) async {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
try {
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
final ok = await _downloadAndVerify(item, dir, file);
|
||||
if (ok) return true;
|
||||
final ok = await _downloadAndVerify(item, dir, file,
|
||||
onProgress: (r, t) {
|
||||
_progress[item.datasetId] = t == 0 ? 0 : r / t;
|
||||
onProgress?.call(r, t);
|
||||
notifyListeners();
|
||||
});
|
||||
if (ok) {
|
||||
_progress.remove(item.datasetId);
|
||||
_errors.remove(item.datasetId);
|
||||
_downloadedIds.add(item.datasetId);
|
||||
await setActive(item.datasetId, true);
|
||||
return true;
|
||||
}
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
}
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载失败,请重试';
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] _ensureLocal ${item.datasetName}: $e');
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载异常:$e';
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _downloadAndVerify(
|
||||
ModelCatalogItem item, Directory dir, File file) async {
|
||||
ModelCatalogItem item, Directory dir, File file,
|
||||
{void Function(int received, int total)? onProgress}) async {
|
||||
final part = File('${file.path}.part');
|
||||
final sink = part.openWrite();
|
||||
var received = 0;
|
||||
try {
|
||||
final res = await _client
|
||||
.send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}')))
|
||||
.timeout(const Duration(minutes: 3));
|
||||
if (res.statusCode != 200) return false;
|
||||
await res.stream.pipe(sink);
|
||||
final total = res.contentLength ?? item.sizeBytes;
|
||||
await for (final chunk in res.stream) {
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, total);
|
||||
}
|
||||
await sink.close();
|
||||
final bytes = await part.readAsBytes();
|
||||
final hex = sha256.convert(bytes).toString();
|
||||
@@ -234,6 +301,45 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置激活状态(true=使用,false=取消);持久化到 <root>/active.json。
|
||||
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
|
||||
Future<void> setActive(int datasetId, bool active) async {
|
||||
final changed =
|
||||
active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId);
|
||||
if (!changed) return;
|
||||
_models = await _loadBundles(_catalog);
|
||||
await _saveActive();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveActive() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
await root.create(recursive: true);
|
||||
await File('${root.path}/active.json')
|
||||
.writeAsString(jsonEncode({'active': _activeIds.toList()}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集持久化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadActive() async {
|
||||
if (_activeLoaded) return;
|
||||
_activeLoaded = true;
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
final f = File('${root.path}/active.json');
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
_activeIds
|
||||
..clear()
|
||||
..addAll((data['active'] as List? ?? const [])
|
||||
.map((e) => (e as num).toInt()));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
|
||||
Reference in New Issue
Block a user