[03-Developer] #311 LLM warm-up + concurrent guard + quickCheck

ChatScreen 마운트 시 백그라운드 native init 으로 첫 send 시점에 native
load 지연을 안 보이게 한다. 12개 AC + UX-Reviewer 의 6개 권고 모두 코드
반영.

핵심 변경:
- `chat_warmup_provider.dart` — `ChatWarmupController` (Idle/Loading/Ready
  /Unavailable/Failed sealed state). fast path (`llm.isLoaded` → Ready),
  FileSystemException ↔ runtime kind 분기, _disposed race guard.
- `model_lifecycle.dart` — `quickCheck()`: 2.4GB SHA-256 hashing 없이
  meta_kv + 파일 존재만 보고 ready 추정 (R4 UX 권고).
- `gemma_llm_service.dart` + `llm_service.dart` — `_loadingFuture` 동시
  호출 가드. 두 caller 가 동시에 load() 해도 native init 은 1 회만.
- `chat_screen.dart` — initState postFrameCallback 에서 warmup.start().
  warmup 상태에 따라 hintText / spinner / 실패 banner 분기.

AC coverage (12개):
- AC1~AC8: ChatWarmupController unit (chat_warmup_test.dart 8 tests).
- AC9~AC12: UX-Reviewer 의 4개 권고 (입력 enabled / send auto-activate /
  fast path no-flicker / 명령형 메시지 금지) — controller 레벨에서 검증.

테스트: 167 passed (1 pre-existing skip). `flutter analyze` clean.

Refs #311

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:52:50 +09:00
parent 1fa4f24a8a
commit 5b4c05316a
8 changed files with 587 additions and 22 deletions

View File

@@ -94,6 +94,44 @@ class ModelLifecycle {
return p.join(dir.path, config.filename);
}
/// Lightweight ready estimate for warm-up gating (#311).
///
/// Skips the SHA-256 re-hash that [checkAvailability] performs — for a
/// ~2.4GB model file the hash is wall-clock-noticeable on every screen
/// mount. Returns `ready` iff:
/// - opt_in is true
/// - download_state is not in-progress
/// - meta_kv has both ai_model_path and ai_model_sha256
/// - the file exists on disk
///
/// Tampering/disk-corruption detection is left to [checkAvailability]'s
/// cold path (SettingsScreen). The trade-off is documented in
/// `docs/design/311-llm-warmup/README.md` §11 R4.
Future<ModelAvailability> quickCheck() async {
try {
final optIn = await meta.find(AiMetaKeys.optIn);
if (optIn != 'true') return ModelAvailability.missing;
final state = await meta.find(AiMetaKeys.downloadState);
if (state == 'downloading' || state == 'paused') {
return ModelAvailability.downloading;
}
final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr == null) return ModelAvailability.missing;
final expected = await meta.find(AiMetaKeys.modelSha);
if (expected == null) return ModelAvailability.corrupt;
final file = File(pathStr);
if (!file.existsSync()) return ModelAvailability.missing;
return ModelAvailability.ready;
} catch (_) {
return ModelAvailability.corrupt;
}
}
Future<ModelAvailability> checkAvailability() async {
try {
final optIn = await meta.find(AiMetaKeys.optIn);