1
This commit is contained in:
@@ -9,7 +9,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 识别档位标识:s = 高识别(@1280 精度优先,默认),n = 高性能(@704 速度优先)
|
||||
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
|
||||
const String kVariantS = 's';
|
||||
const String kVariantN = 'n';
|
||||
|
||||
@@ -85,8 +85,17 @@ class ModelBundle {
|
||||
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`;
|
||||
/// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256},
|
||||
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
|
||||
/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型,
|
||||
/// 切档即热加载新档位已激活模型([mode] 持久化本地)。
|
||||
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
|
||||
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
|
||||
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
|
||||
/// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
|
||||
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
|
||||
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
|
||||
/// 界面并自行调整。
|
||||
/// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘
|
||||
/// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
|
||||
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/自动更新只在网络成功(fetched)
|
||||
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
|
||||
class ModelManager extends ChangeNotifier {
|
||||
static final ModelManager instance = ModelManager._();
|
||||
|
||||
@@ -103,7 +112,6 @@ class ModelManager extends ChangeNotifier {
|
||||
final Set<ModelKey> _cancelRequested = {};
|
||||
String _mode = kVariantS;
|
||||
bool _modeLoaded = false;
|
||||
bool _activeLoaded = false;
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
String? _error;
|
||||
@@ -117,7 +125,8 @@ class ModelManager extends ChangeNotifier {
|
||||
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
|
||||
List<ModelCatalogItem> get catalog => _catalog;
|
||||
|
||||
/// 当前识别档位(s 高识别默认 / n 高性能),持久化,切档即热加载新档位模型
|
||||
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
|
||||
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集)
|
||||
String get mode => _mode;
|
||||
|
||||
bool isActive(int datasetId, String variant) =>
|
||||
@@ -154,7 +163,7 @@ class ModelManager extends ChangeNotifier {
|
||||
_client = client ?? http.Client(),
|
||||
_rootDirOverride = rootDir;
|
||||
|
||||
/// 已激活且已下载、且属于当前档位的模型列表(空 = 未加载任何模型,仅预览)
|
||||
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
|
||||
List<ModelBundle> get models => _models;
|
||||
|
||||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||||
@@ -165,20 +174,19 @@ class ModelManager extends ChangeNotifier {
|
||||
|
||||
bool get refreshing => _refreshing;
|
||||
|
||||
/// 模型名摘要(诊断行展示):数据集名+档位
|
||||
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
|
||||
String get modelsLabel {
|
||||
if (_models.isEmpty) return '未下载';
|
||||
return _models.map((m) => '${m.datasetName}(${m.variant})').join(',');
|
||||
return _models.map((m) => m.datasetName).join(',');
|
||||
}
|
||||
|
||||
/// 切换识别档位:持久化本地并热加载该档位已激活模型(激活集跨档保留)
|
||||
/// 切换识别目标档位:只改偏好并持久化(不切换已在运行的模型——
|
||||
/// 每个动物的实际档位由激活集决定,卡片按钮会面向新目标档给出「改用」操作)
|
||||
Future<void> setMode(String variant) async {
|
||||
if (variant != kVariantS && variant != kVariantN) return;
|
||||
if (_mode == variant) return;
|
||||
_mode = variant;
|
||||
await _saveMode();
|
||||
_models = await _loadBundles(_catalog);
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -194,26 +202,55 @@ class ModelManager extends ChangeNotifier {
|
||||
return _inFlight!;
|
||||
}
|
||||
|
||||
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
|
||||
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
|
||||
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
|
||||
void resetForSession() {
|
||||
if (_active.isEmpty) return;
|
||||
_active.clear();
|
||||
_models = const [];
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _doRefresh() async {
|
||||
try {
|
||||
await _loadActive();
|
||||
await _loadMode();
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
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 [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
|
||||
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
|
||||
if (_catalog.isEmpty) {
|
||||
await _loadCatalogCache();
|
||||
if (_catalog.isNotEmpty) notifyListeners();
|
||||
}
|
||||
var fetched = false;
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
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 [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _saveCatalogCache(list);
|
||||
fetched = true;
|
||||
} catch (e) {
|
||||
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
|
||||
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
}
|
||||
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
|
||||
if (_catalog.isEmpty && !fetched) return;
|
||||
|
||||
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
|
||||
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
|
||||
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
|
||||
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
|
||||
final downloaded = <ModelKey>{};
|
||||
for (final item in _catalog) {
|
||||
if (await _isLocal(item)) {
|
||||
if (await _hasFile(item)) {
|
||||
downloaded.add((datasetId: item.datasetId, variant: item.variant));
|
||||
}
|
||||
}
|
||||
@@ -221,18 +258,15 @@ class ModelManager extends ChangeNotifier {
|
||||
..clear()
|
||||
..addAll(downloaded);
|
||||
|
||||
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、
|
||||
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
|
||||
if (!fetched) return;
|
||||
await _prune(_catalog);
|
||||
// 服务器已下线的 (数据集, 档位) 移出激活集
|
||||
final catalogKeys = _catalog
|
||||
.map((c) => (datasetId: c.datasetId, variant: c.variant))
|
||||
.toSet();
|
||||
final pruned = _active.where((k) => !catalogKeys.contains(k)).toList();
|
||||
if (pruned.isNotEmpty) {
|
||||
for (final k in pruned) {
|
||||
_active.remove(k);
|
||||
}
|
||||
await _saveActive();
|
||||
}
|
||||
_active.removeWhere((k) => !catalogKeys.contains(k));
|
||||
|
||||
_models = await _loadBundles(_catalog);
|
||||
_ready = true;
|
||||
@@ -260,11 +294,14 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta);
|
||||
/// 成功自动加入激活集(下载完成即使用;非当前档位则备好待切档)。
|
||||
/// 失败重试一次并记录错误。
|
||||
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
|
||||
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
|
||||
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
|
||||
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
|
||||
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
|
||||
Future<bool> downloadModel(ModelCatalogItem item,
|
||||
{void Function(int received, int total)? onProgress}) async {
|
||||
{bool autoActivate = true,
|
||||
void Function(int received, int total)? onProgress}) async {
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
|
||||
if (_progress.containsKey(key)) return false;
|
||||
@@ -284,14 +321,25 @@ class ModelManager extends ChangeNotifier {
|
||||
if (ok) {
|
||||
_progress.remove(key);
|
||||
_errors.remove(key);
|
||||
final wasActive = _active.contains(key);
|
||||
_downloaded.add(key);
|
||||
if (item.variant == _mode) {
|
||||
// 字节替换生效(当前档位模型更新需重建 worker 读新文件)
|
||||
if (wasActive) {
|
||||
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
} else if (autoActivate &&
|
||||
!_active.any((k) => k.datasetId == item.datasetId)) {
|
||||
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
|
||||
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
|
||||
final hasTarget = _catalog.any((c) =>
|
||||
c.datasetId == item.datasetId && c.variant == _mode);
|
||||
if (item.variant == _mode || !hasTarget) {
|
||||
_active.add(key);
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
}
|
||||
}
|
||||
_models = await _loadBundles(_catalog);
|
||||
notifyListeners();
|
||||
await setActive(item.datasetId, item.variant, true);
|
||||
return true;
|
||||
}
|
||||
if (_cancelRequested.contains(key)) break;
|
||||
@@ -423,84 +471,49 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`。
|
||||
/// 激活跨档位保留(切档后仍生效);仅影响当前档位加载时才需重建推理 worker。
|
||||
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
|
||||
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。
|
||||
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
|
||||
/// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
|
||||
/// 变化即重建推理 worker。未下载的模型不可激活(下载完成按目标档自动激活)。
|
||||
Future<void> setActive(int datasetId, String variant, bool active) async {
|
||||
final key = (datasetId: datasetId, variant: variant);
|
||||
final changed = active ? _active.add(key) : _active.remove(key);
|
||||
if (!changed) return;
|
||||
if (variant == _mode) {
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
if (!active) {
|
||||
if (!_active.remove(key)) return;
|
||||
} else {
|
||||
final keyActive = _active.contains(key);
|
||||
final sameDsOthers = _active
|
||||
.where((k) => k.datasetId == datasetId && k.variant != variant)
|
||||
.toList();
|
||||
if (keyActive && sameDsOthers.isEmpty) return; // 状态未变化
|
||||
_active.removeAll(sameDsOthers);
|
||||
_active.add(key);
|
||||
}
|
||||
await _saveActive();
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 自动更新:已下载/已激活的模型,目录出现新版本时自动重下(保持原激活状态;
|
||||
/// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知。
|
||||
/// 自动更新:已下载的模型目录出现新版本时自动重下(不改变激活状态——
|
||||
/// 激活只在用户显式下载/启用时发生,2026-09-03 会话制修订)。
|
||||
/// 使用中的模型原地更新则立即生效(重建推理 worker)。未下载的模型不自动拉取,
|
||||
/// 避免无谓流量。下载进度经 downloadModel 通知。
|
||||
/// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。
|
||||
Future<void> autoUpdate() async {
|
||||
if (_catalog.isEmpty) return;
|
||||
final tracked = {..._downloaded, ..._active};
|
||||
for (final item in _catalog) {
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
if (!tracked.contains(key)) continue;
|
||||
final wasActive = _active.contains(key);
|
||||
if (!_downloaded.contains(key)) continue;
|
||||
try {
|
||||
// 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理),
|
||||
// 不得向外抛未处理异步异常
|
||||
if (await _isLocal(item)) continue;
|
||||
final ok = await downloadModel(item);
|
||||
// 原本未激活:下载完成自动激活后恢复原状态
|
||||
if (ok && !wasActive) {
|
||||
await setActive(item.datasetId, item.variant, false);
|
||||
}
|
||||
await downloadModel(item, autoActivate: false);
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveActive() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
await root.create(recursive: true);
|
||||
await File('${root.path}/active.json').writeAsString(jsonEncode({
|
||||
'active': [
|
||||
for (final k in _active) {'d': k.datasetId, 'v': k.variant}
|
||||
]
|
||||
}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集持久化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取激活集。兼容旧版纯 int 列表(双档位前只有 s 档,int 一律归为 s)。
|
||||
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>;
|
||||
for (final e in data['active'] as List? ?? const []) {
|
||||
if (e is num) {
|
||||
_active.add((datasetId: e.toInt(), variant: kVariantS));
|
||||
} else if (e is Map) {
|
||||
final v = e['v'] as String? ?? kVariantS;
|
||||
final d = (e['d'] as num?)?.toInt();
|
||||
if (d != null && v != kVariantS && v != kVariantN) continue;
|
||||
if (d != null) _active.add((datasetId: d, variant: v));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveMode() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
@@ -527,11 +540,42 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
|
||||
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
|
||||
Future<void> _loadCatalogCache() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
final f = File('${root.path}/catalog.json');
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
final list = data['models'] as List? ?? const [];
|
||||
if (list.isEmpty) return;
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 目录缓存读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
|
||||
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
|
||||
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
await root.create(recursive: true);
|
||||
await File('${root.path}/catalog.json')
|
||||
.writeAsString(jsonEncode({'models': rawModels}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 目录缓存保存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
|
||||
Future<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
for (final item in catalog) {
|
||||
if (item.variant != _mode) continue;
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
if (!_active.contains(key)) continue;
|
||||
try {
|
||||
@@ -576,13 +620,24 @@ class ModelManager extends ChangeNotifier {
|
||||
return Directory('${support.path}/models');
|
||||
}
|
||||
|
||||
/// 档位目录:s 档存 `models/<datasetId>/`(legacy 无子目录,目录键 = 档位
|
||||
/// 标识符的 s 形态,存量设备零迁移);n 档存 `models/<datasetId>/n/`。
|
||||
/// 档位子路径(相对模型根目录):s 档 `models/<datasetId>/`(legacy 无子目录,
|
||||
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
|
||||
String _subPath(int datasetId, String variant) =>
|
||||
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
|
||||
|
||||
/// 档位目录(不存在则创建)
|
||||
Future<Directory> _modelDir(int datasetId, String variant) async {
|
||||
final root = await _rootDir();
|
||||
final sub = variant == kVariantS ? '' : '/$variant';
|
||||
final dir = Directory('${root.path}/$datasetId$sub');
|
||||
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
|
||||
/// 新版本由 autoUpdate 自动补齐)
|
||||
Future<bool> _hasFile(ModelCatalogItem item) async {
|
||||
final root = await _rootDir();
|
||||
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
|
||||
.exists();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user