From 0b381f78ba0b5f6d2b7803166582dd24ecb32f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Mon, 31 Aug 2026 15:56:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=9A?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=BC=B9=E5=B1=82=E6=A8=A1=E5=9E=8B=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=888=20=E4=B8=AA=E4=BB=BB=E5=8A=A1=EF=BC=8CTDD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-08-31-模型清单.md | 1337 +++++++++++++++++ 1 file changed, 1337 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-模型清单.md diff --git a/docs/superpowers/plans/2026-08-31-模型清单.md b/docs/superpowers/plans/2026-08-31-模型清单.md new file mode 100644 index 0000000..c9d55dc --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-模型清单.md @@ -0,0 +1,1337 @@ +# 模型清单(设置弹层选择/下载/使用模型)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在「打开视野」相机页设置弹层中增加模型清单(封面缩略图+名称,2 列网格),点「使用」未下载则下载(带进度)并自动激活,已下载直接激活;多选叠加,激活模型并行推理;启动不再自动下载。 + +**Architecture:** 后端在 `/api/v1/app/update` 的 models[] 增加 `coverUrl` 并新增公开封面路由 `/api/v1/app/cover`(按 namePrefix 定位数据集封面);app 端 ModelManager 改为「目录/下载分离」:`refresh()` 只拉目录,新增 `downloadModel()`(进度回调 + 激活)、激活集 `setActive()`(持久化 active.json)、`models` 只返回激活且已下载的模型;设置弹层新增模型清单区块(ModelCatalogSection 独立 widget);激活集变化时相机页重建 DetectorWorker。 + +**Tech Stack:** Go + GoFrame(server)、Flutter/Dart(flutter_app);测试用 GoFrame dao 直查 + `flutter test`(MockClient + 注入 rootDir)。 + +**规格:** `docs/superpowers/specs/2026-08-31-模型清单-design.md` + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `server/biz/dao/dataset.go` | 修改 | 新增 `GetByNamePrefix`(按训练文件名前缀查数据集) | +| `server/biz/service/dataset.go` | 修改 | 新增 `AppCoverFile`(公开封面定位,复用 `CoverFile` 校验) | +| `server/biz/model/dto/app_version.go` | 修改 | 新增 `AppCoverReq`/`AppCoverRes`(路由 `/api/v1/app/cover`) | +| `server/biz/controller/app_version.go` | 修改 | 新增 `Cover` 方法(直写响应体,同 admin Cover 模式) | +| `server/biz/service/model_version.go` | 修改 | `ClientCatalog` 序列化增加 `coverUrl` | +| `server/biz/model/dto/training.go` | 修改 | `ModelCatalogItem` 增加 `CoverUrl` 字段 | +| `flutter_app/lib/models/model_manager.dart` | 修改 | 目录/下载分离 + 激活集 + 进度/错误映射 + `catalog` 暴露 | +| `flutter_app/test/model_manager_test.dart` | 重写 | 新语义下的单测(目录不下载、downloadModel、激活持久化等) | +| `flutter_app/lib/camera/model_catalog_section.dart` | 新建 | 模型清单区块 widget(2 列网格 + 三态按钮 + 进度) | +| `flutter_app/test/model_catalog_section_test.dart` | 新建 | 弹层区块 widget 测试 | +| `flutter_app/lib/camera/camera_screen.dart` | 修改 | 弹层挂载区块、`_reloadWorker` 抽取、激活变化监听、无模型横幅文案 | + +--- + +### Task 1: 后端 dao — GetByNamePrefix + +**Files:** +- Modify: `server/biz/dao/dataset.go`(`GetByName` 之后,约 109 行后) + +- [ ] **Step 1: 实现** + +在 `server/biz/dao/dataset.go` 的 `GetByName` 函数后追加: + +```go +// GetByNamePrefix 按训练文件名前缀查询(模型发布命名,前缀唯一),不存在返回 nil +func (d *datasetDao) GetByNamePrefix(ctx context.Context, prefix string) (*entity.Dataset, error) { + var e entity.Dataset + err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("name_prefix", prefix).Scan(&e) + if err != nil { + if common.IsNoRows(err) { + return nil, nil + } + return nil, err + } + return &e, nil +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `cd server && go build ./...` +Expected: 无输出、exit 0 + +- [ ] **Step 3: Commit** + +```bash +git add server/biz/dao/dataset.go +git commit -m "feat(server): dao 增加 GetByNamePrefix(封面公开路由定位用)" +``` + +--- + +### Task 2: 后端 — 公开封面路由(DTO + service + controller) + +**Files:** +- Modify: `server/biz/model/dto/app_version.go` +- Modify: `server/biz/service/dataset.go` +- Modify: `server/biz/controller/app_version.go` + +- [ ] **Step 1: DTO** + +`server/biz/model/dto/app_version.go` 文件末尾追加: + +```go +// AppCoverReq 数据集封面(公开接口:App 模型清单缩略图,无需登录态)。 +// namePrefix 为训练文件名前缀(模型发布命名,与 downloadUrl 文件名一致)。 +type AppCoverReq struct { + g.Meta `path:"/app/cover" method:"get" summary:"数据集封面" tags:"版本"` + NamePrefix string `json:"namePrefix" v:"required"` +} + +// AppCoverRes 封面响应(controller 直写响应体,此结构仅占位) +type AppCoverRes struct{} +``` + +- [ ] **Step 2: service** + +`server/biz/service/dataset.go` 的 `CoverFile` 函数后追加(该文件已 import dao/common/gerror/strings): + +```go +// AppCoverFile 公开封面定位(App 模型清单缩略图):按训练文件名前缀查数据集, +// 复用 CoverFile 的归属校验(数据集不存在/无封面/文件缺失 → 404 错误码)。 +func (s *datasetService) AppCoverFile(ctx context.Context, namePrefix string) (string, error) { + d, err := dao.Dataset.GetByNamePrefix(ctx, strings.TrimSpace(namePrefix)) + if err != nil { + return "", err + } + if d == nil { + return "", gerror.NewCode(common.CodeDatasetNotFound) + } + return s.CoverFile(ctx, d.Id, "", "") +} +``` + +- [ ] **Step 3: controller** + +`server/biz/controller/app_version.go` 改为(import 增加 `"github.com/gogf/gf/v2/net/ghttp"`): + +```go +package controller + +import ( + "context" + + "github.com/gogf/gf/v2/net/ghttp" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cAppVersion App 版本接口:公开组(无需登录态,旧版本/未登录用户均可检查更新)。 +type cAppVersion struct{} + +var AppVersion = &cAppVersion{} + +// GetUpdate 版本更新检查 +func (c *cAppVersion) GetUpdate(ctx context.Context, req *dto.AppUpdateReq) (*dto.AppUpdateRes, error) { + return service.AppVersion.GetUpdate(ctx, req) +} + +// Cover 数据集封面(直写响应体:service 定位文件,controller 输出二进制; +// 公开接口,App 模型清单缩略图。URL 不随封面更换变化,必须 no-store 防缓存旧图) +func (c *cAppVersion) Cover(ctx context.Context, req *dto.AppCoverReq) (*dto.AppCoverRes, error) { + path, err := service.Dataset.AppCoverFile(ctx, req.NamePrefix) + if err != nil { + return nil, err + } + r := ghttp.RequestFromCtx(ctx) + r.Response.Header().Set("Cache-Control", "no-store") + r.Response.ServeFile(path) + return &dto.AppCoverRes{}, nil +} +``` + +- [ ] **Step 4: 编译 + 手工验证** + +Run: `cd server && go build ./...` +Expected: 无输出、exit 0 + +若本地服务在跑(默认 :8080): +```bash +curl -s -o /dev/null -w "%{http_code} %{content_type}\n" "http://127.0.0.1:8080/api/v1/app/cover?namePrefix=RNPHE" +curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8080/api/v1/app/cover?namePrefix=不存在的" +``` +Expected: 第一行 `200 image/jpeg`(或图片 mime),第二行 `500`(统一包装的错误码;若无 404 专门码,确认返回非 200 即可)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/biz/model/dto/app_version.go server/biz/service/dataset.go server/biz/controller/app_version.go +git commit -m "feat(server): 公开封面路由 /api/v1/app/cover(App 模型清单缩略图)" +``` + +--- + +### Task 3: 后端 — models[] 增加 coverUrl + +**Files:** +- Modify: `server/biz/model/dto/training.go` +- Modify: `server/biz/service/model_version.go` + +- [ ] **Step 1: DTO 字段** + +`server/biz/model/dto/training.go` 的 `ModelCatalogItem`(约 449-461 行)中 `DownloadUrl` 字段后追加: + +```go + CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图) +``` + +- [ ] **Step 2: 序列化** + +`server/biz/service/model_version.go`: +- import 增加 `"net/url"` +- `ClientCatalog` 循环内 `DownloadUrl` 字段后追加 `CoverUrl`: + +```go + DownloadUrl: "/download/trainings/" + modelNames[v.DatasetId] + ".tflite", + CoverUrl: "/api/v1/app/cover?namePrefix=" + url.PathEscape(modelNames[v.DatasetId]), +``` + +说明:`modelNames` 来自 `datasetModelNameMap`,值即「前缀优先、空回退数据集名」的模型文件基名(`modelFileName`),与封面定位基名一致,无需新增查询。 + +- [ ] **Step 3: 编译 + 手工验证** + +Run: `cd server && go build ./...` Expected: exit 0 + +若本地服务在跑: +```bash +curl -s "http://127.0.0.1:8080/api/v1/app/update" | head -c 600 +``` +Expected: models[] 条目含 `"coverUrl":"/api/v1/app/cover?namePrefix=RNPHE"`(或对应前缀)。 + +- [ ] **Step 4: Commit** + +```bash +git add server/biz/model/dto/training.go server/biz/service/model_version.go +git commit -m "feat(server): 模型目录 models[] 增加 coverUrl(封面缩略图)" +``` + +--- + +### Task 4: app — ModelCatalogItem.coverUrl 字段 + +**Files:** +- Modify: `flutter_app/lib/models/model_manager.dart` + +- [ ] **Step 1: 加字段** + +`ModelCatalogItem` 类(model_manager.dart:13-30)中 `downloadUrl` 后追加: + +```dart + final String coverUrl; + + // fromJson 中 downloadUrl 行后追加: + const ModelCatalogItem({ + ... + required this.downloadUrl, + this.coverUrl = '', + }); + + // fromJson 中 downloadUrl 行后追加: + coverUrl: j['coverUrl'] as String? ?? '', +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd flutter_app && flutter analyze` +Expected: No issues found + +- [ ] **Step 3: Commit** + +```bash +git add flutter_app/lib/models/model_manager.dart +git commit -m "feat(app): ModelCatalogItem 增加 coverUrl 字段" +``` + +--- + +### Task 5: app — ModelManager 改造(目录/下载分离 + 激活集)【TDD】 + +**Files:** +- Rewrite: `flutter_app/test/model_manager_test.dart` +- Modify: `flutter_app/lib/models/model_manager.dart` + +- [ ] **Step 1: 重写测试(先红)** + +整体替换 `flutter_app/test/model_manager_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:observer/models/model_manager.dart'; + +const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; + +String _shaHex(List bytes) => sha256.convert(bytes).toString(); + +Map _catalog(List> models) => { + 'code': 0, + 'message': 'ok', + 'data': {'version': '0.0.2', 'notes': '', 'models': models} + }; + +Map _item({ + int datasetId = 7, + String name = '环颈雉鸡数据集', + String version = 'v1.0.0', + String sha = '', +}) => + { + 'datasetId': datasetId, + 'datasetName': name, + 'version': version, + 'labels': ['pheasant', 'suspect'], + 'sizeBytes': _modelBytes.length, + 'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha, + 'downloadUrl': '/download/models/$datasetId/latest.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + }; + +void main() { + late Directory root; + late int downloadHits; + + setUp(() async { + root = await Directory.systemTemp.createTemp('model-manager-test'); + downloadHits = 0; + }); + + tearDown(() => root.delete(recursive: true)); + + ModelManager manager(MockClient client) => ModelManager( + baseUrl: 'http://test.local', + client: client, + rootDir: () async => root, + ); + + MockClient client(List> models) => + MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + // Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes + return http.Response.bytes( + utf8.encode(jsonEncode(_catalog(models))), 200); + } + if (req.url.path.startsWith('/download/models/')) { + downloadHits++; + return http.Response.bytes(_modelBytes, 200); + } + return http.Response('not found', 404); + }); + + test('refresh 只拉目录:不下载任何模型,coverUrl 解析正确', () async { + final m = manager(client([_item()])); + await m.refresh(); + + expect(m.ready, isTrue); + expect(m.error, isNull); + expect(downloadHits, 0, reason: 'refresh 不应触发下载'); + expect(m.models, isEmpty, reason: '未激活的模型不应出现在 models'); + expect(m.catalog.length, 1); + expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=RNPHE'); + }); + + test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async { + final m = manager(client([_item()])); + await m.refresh(); + + final progresses = []; + final ok = await m.downloadModel(m.catalog.first, onProgress: (r, t) { + progresses.add(t == 0 ? 0 : r / t); + }); + + expect(ok, isTrue); + expect(downloadHits, 1); + expect(m.isDownloaded(7), isTrue); + expect(m.isActive(7), isTrue, reason: '下载完成应自动使用'); + expect(progresses.last, 1.0); + expect(m.models.length, 1); + expect(m.models.first.datasetName, '环颈雉鸡数据集'); + + final dir = Directory('${root.path}/7'); + expect(await File('${dir.path}/model.tflite').exists(), isTrue); + expect(await File('${dir.path}/labels.json').exists(), isTrue); + expect(await File('${dir.path}/meta.json').exists(), isTrue); + }); + + test('已下载未激活:setActive 直接使用,不触发下载', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); + await m.setActive(7, false); + expect(m.isActive(7), isFalse); + expect(m.models, isEmpty); + + final before = downloadHits; + await m.setActive(7, true); + expect(m.isActive(7), isTrue); + expect(downloadHits, before, reason: '已下载直接使用不应重新下载'); + expect(m.models.length, 1); + }); + + test('版本更新:downloadModel 重新下载', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); + expect(downloadHits, 1); + + final m2 = manager(client([_item(version: 'v2.0.0')])); + await m2.refresh(); + await m2.downloadModel(m2.catalog.first); + expect(downloadHits, 2); + expect(m2.models.first.version, 'v2.0.0'); + }); + + test('sha256 不匹配:重试后失败、不激活、错误可见', () async { + final m = manager(client([_item()])); + await m.refresh(); + final bad = _item(version: 'v3.0.0'); + bad['sha256'] = _shaHex([9, 9, 9]); + final m2 = manager(client([bad])); + await m2.refresh(); + + final ok = await m2.downloadModel(m2.catalog.first); + expect(ok, isFalse); + expect(downloadHits, 2, reason: '校验失败应重试一次'); + expect(m2.isDownloaded(7), isFalse); + expect(m2.isActive(7), isFalse); + expect(m2.errorOf(7), isNotNull); + }); + + test('激活集持久化:重启后恢复激活且已下载的模型', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); + + // 同一 root 新建 manager 模拟重启 + final m2 = manager(client([_item()])); + await m2.refresh(); + expect(m2.isActive(7), isTrue, reason: '激活集应持久化'); + expect(m2.models.length, 1); + expect(downloadHits, 1, reason: '重启不应触发下载'); + }); + + test('目录下线:清理本地并移除激活', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); + expect(await Directory('${root.path}/7').exists(), isTrue); + + final m2 = manager(client([])); + await m2.refresh(); + expect(m2.models, isEmpty); + expect(m2.isActive(7), isFalse, reason: '下线的模型应移出激活集'); + expect(await Directory('${root.path}/7').exists(), isFalse, + reason: '下线的数据集模型目录应被清理'); + }); + + test('多模型:只激活其一则只加载其一', () async { + final m = manager(client([_item(datasetId: 7, name: '环颈雉'), _item(datasetId: 8, name: '斑鸠')])); + await m.refresh(); + await m.downloadModel(m.catalog.first); // 只下载并激活 7 + expect(m.models.length, 1); + expect(m.models.first.datasetId, 7); + + await m.downloadModel(m.catalog.last); // 下载 8 自动激活 + expect(m.models.length, 2); + }); + + test('目录接口异常:不覆盖已有就绪状态', () async { + final m = manager(client([_item()])); + await m.refresh(); + await m.downloadModel(m.catalog.first); + expect(m.ready, isTrue); + + final broken = manager(MockClient((_) async => http.Response('boom', 500))); + await broken.refresh(); + expect(broken.ready, isFalse); + expect(broken.error, isNotNull); + }); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd flutter_app && flutter test test/model_manager_test.dart` +Expected: 编译失败(`catalog`/`isDownloaded`/`setActive`/`downloadModel`/`errorOf` 不存在) + +- [ ] **Step 3: 实现 ModelManager 改造** + +`flutter_app/lib/models/model_manager.dart` 修改如下(保持 `refresh()`/`_ready`/`_error`/`modelsLabel` 对外签名不变): + +a) 类内新增字段与访问器(`_models` 声明附近): + +```dart + List _catalog = const []; + final Set _activeIds = {}; + final Set _downloadedIds = {}; + final Map _progress = {}; + final Map _errors = {}; + bool _activeLoaded = false; + + /// 服务器目录(弹层模型清单展示用) + List get catalog => _catalog; + + /// 激活模型 id 集合(多选叠加) + Set 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]; +``` + +b) `_doRefresh` 整体替换为(只拉目录 + 扫描已下载 + 清理 + 恢复激活): + +```dart + Future _doRefresh() async { + try { + await _loadActive(); + final res = await _client + .get(Uri.parse('$baseUrl/api/v1/app/update')) + .timeout(const Duration(seconds: 8)); + // 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8 + final body = + jsonDecode(utf8.decode(res.bodyBytes)) as Map; + final data = body['data'] as Map? ?? const {}; + final list = data['models'] as List? ?? const []; + _catalog = list + .map((e) => ModelCatalogItem.fromJson(e as Map)) + .toList(); + + // 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示 + final downloaded = {}; + for (final item in _catalog) { + if (await _isLocal(item)) downloaded.add(item.datasetId); + } + _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 = null; + } catch (e) { + if (!_ready) _error = '模型目录拉取失败:$e'; + // 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示) + } + } +``` + +c) 原 `_ensureLocal` 改为两个方法(保留 meta 校验与下载校验逻辑拆分): + +```dart + /// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在) + Future _isLocal(ModelCatalogItem item) async { + final dir = await _modelDir(item.datasetId); + try { + final meta = await _readMeta(dir); + final file = File('${dir.path}/model.tflite'); + return meta != null && + meta['version'] == item.version && + meta['sha256'] == item.sha256 && + await file.exists(); + } catch (e) { + return false; + } + } + + /// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta); + /// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。 + Future 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, + 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) { + _progress.remove(item.datasetId); + _errors[item.datasetId] = '下载异常:$e'; + notifyListeners(); + debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); + return false; + } + } +``` + +d) `_downloadAndVerify` 增加进度回调参数(原签名 `(item, dir, file)` → 加 `{onProgress}`,在流式 pipe 处计数;注意 `res.contentLength` 可能为 -1,此时进度只报 0/total): + +```dart + Future _downloadAndVerify( + 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; + 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(); + if (item.sha256.isNotEmpty && hex != item.sha256) { + debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} ' + 'want=${item.sha256} got=$hex'); + return false; + } + await part.rename(file.path); + await dir.create(recursive: true); + await File('${dir.path}/labels.json') + .writeAsString(jsonEncode(item.labels)); + await File('${dir.path}/meta.json').writeAsString(jsonEncode({ + 'version': item.version, + 'sha256': item.sha256, + })); + debugPrint('[ModelManager] 已下载 ${item.datasetName} ' + '${bytes.length}B -> ${file.path}'); + return true; + } catch (e) { + await sink.close().catchError((_) {}); + debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); + return false; + } + } +``` + +e) 新增激活集持久化(`_loadBundles` 附近): + +```dart + /// 设置激活状态(true=使用,false=取消);持久化到 /active.json。 + /// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。 + Future setActive(int datasetId, bool active) async { + final changed = + active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId); + if (!changed) return; + await _saveActive(); + notifyListeners(); + } + + Future _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 _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; + _activeIds + ..clear() + ..addAll((data['active'] as List? ?? const []) + .map((e) => (e as num).toInt())); + } catch (e) { + debugPrint('[ModelManager] 激活集读取失败: $e'); + } + } +``` + +f) `_loadBundles` 只加载激活且已下载的模型(`_prune` 保持原样不动): + +```dart + Future> _loadBundles( + List catalog) async { + final bundles = []; + for (final item in catalog) { + if (!_activeIds.contains(item.datasetId)) continue; + try { + final dir = await _modelDir(item.datasetId); + final file = File('${dir.path}/model.tflite'); + if (!await file.exists()) continue; + final labels = await File('${dir.path}/labels.json').exists() + ? (jsonDecode( + await File('${dir.path}/labels.json').readAsString()) + as List) + .map((e) => e.toString()) + .toList() + : item.labels; + bundles.add(ModelBundle( + datasetId: item.datasetId, + datasetName: item.datasetName, + version: item.version, + labels: labels, + bytes: await file.readAsBytes(), + )); + } catch (e) { + debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e'); + } + } + return bundles; + } +``` + +g) 类注释与 `modelsLabel` 文案更新(`内置` → `未下载`,注释去掉「回退内置资产」表述): + +```dart + /// 模型名摘要(诊断行展示):未下载 / 数据集名×n + String get modelsLabel { + if (_models.isEmpty) return '未下载'; + return _models.map((m) => m.datasetName).join(','); + } +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd flutter_app && flutter test test/model_manager_test.dart` +Expected: 全部 9 个测试 PASS + +- [ ] **Step 5: 全量测试确认无回归** + +Run: `cd flutter_app && flutter test` +Expected: 全部 PASS(widget_test 若因 modelsLabel 文案断言失败需同步更新断言) + +- [ ] **Step 6: Commit** + +```bash +git add flutter_app/lib/models/model_manager.dart flutter_app/test/model_manager_test.dart +git commit -m "feat(app): ModelManager 目录/下载分离 + 激活集持久化 + 下载进度" +``` + +--- + +### Task 6: app — 设置弹层模型清单 UI【TDD】 + +**Files:** +- Create: `flutter_app/lib/camera/model_catalog_section.dart` +- Create: `flutter_app/test/model_catalog_section_test.dart` +- Modify: `flutter_app/lib/camera/camera_screen.dart` + +- [ ] **Step 1: 新建区块 widget** + +`flutter_app/lib/camera/model_catalog_section.dart`: + +```dart +import 'package:flutter/material.dart'; + +import '../models/model_manager.dart'; + +/// 设置弹层「模型清单」区块:2 列封面缩略图网格。 +/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活; +/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。 +class ModelCatalogSection extends StatelessWidget { + final ModelManager manager; + + const ModelCatalogSection({super.key, required this.manager}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: manager, + builder: (context, _) { + final items = manager.catalog; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('模型清单', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold)), + const Spacer(), + TextButton.icon( + onPressed: () => manager.refresh(), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('刷新'), + style: TextButton.styleFrom( + foregroundColor: Colors.white70, + visualDensity: VisualDensity.compact), + ), + ], + ), + const SizedBox(height: 4), + if (manager.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(manager.error!, + style: const TextStyle( + color: Colors.orange, fontSize: 12)), + ), + if (items.isEmpty) + const Text('暂无已发布模型', + style: TextStyle(color: Colors.white54, fontSize: 13)) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.72, + ), + itemCount: items.length, + itemBuilder: (context, i) => + _ModelCard(item: items[i], manager: manager), + ), + ], + ); + }, + ); + } +} + +class _ModelCard extends StatelessWidget { + final ModelCatalogItem item; + final ModelManager manager; + + const _ModelCard({required this.item, required this.manager}); + + @override + Widget build(BuildContext context) { + final active = manager.isActive(item.datasetId); + final downloaded = manager.isDownloaded(item.datasetId); + final progress = manager.progressOf(item.datasetId); + final error = manager.errorOf(item.datasetId); + + final thumb = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Image.network( + '${manager.baseUrl}${item.coverUrl}', + fit: BoxFit.cover, + loadingBuilder: (context, child, chunk) => chunk == null + ? child + : Container( + color: Colors.white12, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2)))), + errorBuilder: (context, error, stack) => Container( + color: Colors.white12, + child: const Icon(Icons.image_not_supported_outlined, + color: Colors.white38), + ), + ), + ), + ); + + Widget action; + if (progress != null) { + action = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.white12, + color: Colors.greenAccent), + const SizedBox(height: 4), + Text('${(progress * 100).toStringAsFixed(0)}%', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 11)), + ], + ); + } else if (error != null) { + action = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(error, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.redAccent, fontSize: 10)), + TextButton( + onPressed: () => manager.downloadModel(item), + child: const Text('重试', style: TextStyle(fontSize: 12)), + ), + ], + ); + } else if (active) { + action = SizedBox( + height: 30, + child: OutlinedButton( + onPressed: () => manager.setActive(item.datasetId, false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.greenAccent, + side: const BorderSide(color: Colors.greenAccent)), + child: const Text('已使用', style: TextStyle(fontSize: 12)), + ), + ); + } else if (downloaded) { + action = SizedBox( + height: 30, + child: FilledButton( + onPressed: () => manager.setActive(item.datasetId, true), + style: FilledButton.styleFrom( + backgroundColor: Colors.greenAccent, + foregroundColor: Colors.black, + visualDensity: VisualDensity.compact), + child: const Text('使用', style: TextStyle(fontSize: 12)), + ), + ); + } else { + action = SizedBox( + height: 30, + child: FilledButton( + onPressed: () => manager.downloadModel(item), + style: FilledButton.styleFrom( + backgroundColor: Colors.greenAccent, + foregroundColor: Colors.black, + visualDensity: VisualDensity.compact), + child: const Text('使用', style: TextStyle(fontSize: 12)), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Center(child: thumb)), + const SizedBox(height: 6), + Text(item.datasetName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text('v${item.version}', + style: const TextStyle(color: Colors.white38, fontSize: 10)), + const SizedBox(height: 6), + action, + ], + ), + ); + } +} +``` + +- [ ] **Step 2: 新建 widget 测试** + +`flutter_app/test/model_catalog_section_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:observer/camera/model_catalog_section.dart'; +import 'package:observer/models/model_manager.dart'; + +const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; +String _shaHex(List bytes) => sha256.convert(bytes).toString(); + +Map _item() => { + 'datasetId': 7, + 'datasetName': '环颈雉鸡数据集', + 'version': 'v1.0.0', + 'labels': ['pheasant', 'suspect'], + 'sizeBytes': _modelBytes.length, + 'sha256': _shaHex(_modelBytes), + 'downloadUrl': '/download/models/7/latest.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + }; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('catalog-section-test'); + }); + + tearDown(() => root.delete(recursive: true)); + + ModelManager manager() => ModelManager( + baseUrl: 'http://test.local', + client: MockClient((req) async { + if (req.url.path == '/api/v1/app/update') { + return http.Response.bytes( + utf8.encode(jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'models': [_item()]} + })), + 200); + } + if (req.url.path.startsWith('/download/models/')) { + return http.Response.bytes(_modelBytes, 200); + } + return http.Response('not found', 404); + }), + rootDir: () async => root, + ); + + Future pumpSection(WidgetTester tester, ModelManager m) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ModelCatalogSection(manager: m)))); + await tester.pumpAndSettle(); + } + + testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async { + final m = manager(); + await m.refresh(); + await pumpSection(tester, m); + + expect(find.text('环颈雉鸡数据集'), findsOneWidget); + expect(find.text('使用'), findsOneWidget); + + await tester.tap(find.text('使用')); + await tester.pumpAndSettle(); + + expect(m.isDownloaded(7), isTrue); + expect(m.isActive(7), isTrue); + expect(find.text('已使用'), findsOneWidget); + }); + + testWidgets('已激活:再次点击取消使用', (tester) async { + final m = manager(); + await m.refresh(); + await m.downloadModel(m.catalog.first); + await pumpSection(tester, m); + + expect(find.text('已使用'), findsOneWidget); + await tester.tap(find.text('已使用')); + await tester.pumpAndSettle(); + + expect(m.isActive(7), isFalse); + expect(find.text('使用'), findsOneWidget); + }); + + testWidgets('目录为空:显示暂无已发布模型', (tester) async { + final m = ModelManager( + baseUrl: 'http://test.local', + client: MockClient((_) async => http.Response.bytes( + utf8.encode(jsonEncode( + {'code': 0, 'message': 'ok', 'data': {'models': []}})), + 200)), + rootDir: () async => root, + ); + await m.refresh(); + await pumpSection(tester, m); + + expect(find.text('暂无已发布模型'), findsOneWidget); + }); +} +``` + +注意:`Image.network` 在 widget 测试中会发起真实 HTTP(测试环境返回 400),`errorBuilder` 兜底显示占位图标,不影响按钮断言;若测试报网络相关异常,用 `tester.runAsync` 或忽略(errorBuilder 已兜底)。 + +- [ ] **Step 3: 跑测试确认通过** + +Run: `cd flutter_app && flutter test test/model_catalog_section_test.dart` +Expected: 3 个测试 PASS(Image.network 400 由 errorBuilder 兜底) + +- [ ] **Step 4: 挂载到设置弹层** + +`flutter_app/lib/camera/camera_screen.dart`: +- import 增加:`import 'model_catalog_section.dart';` +- `_openSettings()` 改为 `isScrollControlled: true`,builder 内容包 `SingleChildScrollView`,滑块提示文字后追加分隔线与区块;弹层打开时触发一次目录刷新: + +```dart + void _openSettings() { + final vm = _viewModel; + if (vm == null) return; + ModelManager.instance.refresh(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.black87, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('识别设置', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Row( + children: [ + const Text('置信度阈值', + style: + TextStyle(color: Colors.white70, fontSize: 14)), + const Spacer(), + Text('${(_minScore * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.greenAccent, + fontSize: 14, + fontWeight: FontWeight.bold)), + ], + ), + Slider( + value: _minScore, + min: 0.05, + max: 0.50, + divisions: 45, + activeColor: Colors.greenAccent, + onChanged: (v) { + setSheetState(() => _minScore = v); + _analyzer?.worker?.setMinScore(v); + }, + ), + const SizedBox(height: 8), + const Text( + '阈值越低识别越灵敏(低分框越多,误报也可能增加);' + '环颈雉鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 16), + const Divider(color: Colors.white12), + const SizedBox(height: 8), + ModelCatalogSection(manager: ModelManager.instance), + ], + ), + ), + ), + ), + ); + } +``` + +- [ ] **Step 5: 全量测试 + 分析** + +Run: `cd flutter_app && flutter analyze && flutter test` +Expected: No issues found;全部 PASS + +- [ ] **Step 6: Commit** + +```bash +git add flutter_app/lib/camera/model_catalog_section.dart flutter_app/test/model_catalog_section_test.dart flutter_app/lib/camera/camera_screen.dart +git commit -m "feat(app): 设置弹层模型清单(2列缩略图网格+使用/下载进度/取消)" +``` + +--- + +### Task 7: app — 激活变化时相机 worker 重建 + +**Files:** +- Modify: `flutter_app/lib/camera/camera_screen.dart` + +- [ ] **Step 1: 抽取 _reloadWorker + 监听激活变化** + +`flutter_app/lib/camera/camera_screen.dart`: + +a) 类字段新增: + +```dart + /// 当前 worker 已加载的模型 id 集合(激活集变化对比用) + Set _loadedModelIds = const {}; + bool _reloadingWorker = false; +``` + +b) `initState` 末尾加监听,`dispose` 加移除: + +```dart + ModelManager.instance.addListener(_onModelsChanged); + // dispose() 中: + ModelManager.instance.removeListener(_onModelsChanged); +``` + +c) `_init()` 中 worker/analyzer 创建段替换为调用 `_reloadWorker()`: + +```dart + // 模型热更新:启动拉取目录(只拉不下载),此处兜底等待;未下载模型时仅预览 + if (!ModelManager.instance.ready) { + try { + await ModelManager.instance + .refresh() + .timeout(const Duration(seconds: 15)); + } catch (_) {} + } + await _reloadWorker(); + await _startCamera(); +``` + +d) 新增方法(`_init` 之后): + +```dart + /// 用当前激活模型重建推理 worker(激活集变化/启动时调用); + /// worker 为 null(无激活模型或加载失败)时仅预览并提示。 + Future _reloadWorker() async { + if (_reloadingWorker) return; + _reloadingWorker = true; + try { + final models = ModelManager.instance.models; + final ids = models.map((m) => m.datasetId).toSet(); + if (_viewModel != null && + _loadedModelIds.length == ids.length && + _loadedModelIds.containsAll(ids)) { + return; // 激活集未变(进度/目录刷新通知)直接跳过 + } + final worker = await DetectorWorker.create(models: models); + _loadedModelIds = ids; + final vm = _viewModel ?? CameraViewModel(reminder: Reminder()); + vm.setModelReady(worker != null); + final analyzer = FrameAnalyzer(worker: worker, viewModel: vm); + final old = _analyzer; + if (mounted) { + setState(() { + _viewModel = vm; + _analyzer = analyzer; + }); + } + old?.worker?.dispose(); + // 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅) + if (mounted && _cameraController != null) { + await _cameraController!.start(analyzer); + } + } finally { + _reloadingWorker = false; + } + } + + void _onModelsChanged() { + final ids = + ModelManager.instance.models.map((m) => m.datasetId).toSet(); + if (_loadedModelIds.length == ids.length && + _loadedModelIds.containsAll(ids)) { + return; + } + _reloadWorker(); + } +``` + +- [ ] **Step 2: 无模型横幅文案区分「未使用」与「加载失败」** + +`_buildDiagnosticsLayer` 中 `!vm.state.modelReady` 的横幅文本改为: + +```dart + child: Text( + ModelManager.instance.models.isEmpty + ? '未使用模型:点右上角设置,在「模型清单」中选择要使用的模型' + : '识别模型加载失败:${DetectorWorker.lastLoadError ?? '未知原因'}\n最后步骤:${DetectorWorker.lastLog ?? '-'}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.orange, fontSize: 14), + ), +``` + +- [ ] **Step 3: 验证** + +Run: `cd flutter_app && flutter analyze && flutter test` +Expected: No issues found;全部 PASS + +- [ ] **Step 4: Commit** + +```bash +git add flutter_app/lib/camera/camera_screen.dart +git commit -m "feat(app): 激活模型变化时重建推理 worker(无模型仅预览并提示选择)" +``` + +--- + +### Task 8: 全量验证 + 重新打包 APK + +- [ ] **Step 1: 后端全量编译** + +Run: `cd server && go build ./...` +Expected: exit 0 + +- [ ] **Step 2: app 全量测试** + +Run: `cd flutter_app && flutter analyze && flutter test` +Expected: No issues found;全部 PASS + +- [ ] **Step 3: 重新打包 release APK(版本号已递增为 1.0.5+6)** + +Run: `cd flutter_app && flutter build apk --release` +Expected: `✓ Built build/app/outputs/flutter-apk/app-release.apk` + +- [ ] **Step 4: 更新 flutter_app/README.md 模型热更新章节** + +在「模型热更新(多数据集模型)」章节(约 30-51 行)补一句下载入口: + +```markdown +- **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格, + 未下载点击「使用」显示进度,完成自动激活;已激活再次点击取消;多模型并行推理)。 +``` + +- [ ] **Step 5: 更新记忆(flutter_build_env.md 模型资产条目已过时)** + +将 memory 中 `flutter_build_env.md` 的「模型资产 assets/model.tflite 是 NHWC 字节级手术版…」条目更新为「模型不再打包进 APK,app 内按需下载(模型清单功能,2026-08-31)」;NHWC 手术改为训练产物发布到服务器前处理。 + +--- + +## 自审记录 + +- **规格覆盖**:coverUrl+公开路由 → Task 1/2/3;ModelManager 目录分离+进度+激活 → Task 5;弹层 UI 2 列缩略图+三态按钮 → Task 6;相机 worker 重建联动 → Task 7;错误处理(目录失败/图片失败/下载失败)→ Task 5/6;测试 → Task 5/6 内嵌。 +- **已知取舍**:`downloadModel` 的进度回调在每次流式 chunk 时 notifyListeners(约百次/模型,单模型串行下载,可接受);激活集仅在下载成功或文件已存在时可见(`setActive` 未对未下载 id 做拦截,UI 层保证调用时机)。 +- **风险**:Task 6 的 `Image.network` 在 widget 测试环境发请求返回 400,已用 errorBuilder 兜底;旧测试对 `modelsLabel`/自动下载行为的断言已在 Task 5 重写时同步。