# LLM warm-up (#311) > 적용 버전: **v0.4.1 이상** · Redmine #311 · 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/) `ChatScreen` 진입 시 LLM 모델을 백그라운드로 warm-up 하여 첫 send 의 cold native-init 비용 (수 초) 을 제거한다. ## 상태 머신 `app/lib/state/chat_warmup_provider.dart` — `sealed class ChatWarmupState`. | 상태 | 진입 조건 | UI 동작 | |------|-----------|---------| | `Idle` | 초기값 | 평상 | | `Loading` | `quickCheck = ready` + `llm.isLoaded = false` → `llm.load()` in-flight | hintText 교체, send 자리 spinner | | `Ready` | `load()` 성공 또는 `llm.isLoaded = true` fast path | 평상, send 즉시 활성 | | `Failed(kind)` | `load()` throw | `_WarmupErrorBanner` 표시 | | `Unavailable` | `quickCheck != ready` (opt-out / downloading / missing) | 평상 (warmup 라벨 X, 기존 lazy 경로 fallback) | `ChatWarmupFailureKind`: - `fileMissing` — `FileSystemException`. 회복 = `SettingsScreen` push (재다운로드). - `runtime` — 기타. 회복 = `retry()`. ## API ### `ChatWarmupController` ```dart final chatWarmupProvider = StateNotifierProvider.autoDispose(...); ``` - `Future start()` — quickCheck → load → state 전이. 재진입 가드 (Loading 중 호출은 no-op). `ChatScreen.initState` 의 `postFrameCallback` 에서 호출. - `Future retry()` — Idle reset 후 start() 재호출. - dispose 시 `_disposed = true` → `_safeSet` 가 후속 state 변경 무시. ### `ModelLifecycle.quickCheck()` ```dart Future quickCheck(); ``` `checkAvailability()` 의 lightweight 버전. SHA-256 재해싱 (~2.4GB) 을 건너뛰고 meta_kv + 파일 존재만으로 ready 추정. tamper 감지는 `checkAvailability()` 의 cold path (SettingsScreen) 에 위임. | meta_kv 상태 | quickCheck 반환 | |---|---| | `ai_opt_in != 'true'` | `missing` | | `ai_download_state in (downloading, paused)` | `downloading` | | `ai_model_path` null | `missing` | | `ai_model_sha256` null | `corrupt` | | 파일 부재 | `missing` | | 그 외 | `ready` | | 내부 throw | `corrupt` (보수적 fallback) | ### Concurrent load guard `GemmaLlmService.load()` + `MockLlmService.load()` 가 `_loadingFuture` 가드 공유: ```dart Future load() { if (_loaded) return Future.value(); final existing = _loadingFuture; if (existing != null) return existing; final future = _doLoad(); _loadingFuture = future; return future.whenComplete(() { _loadingFuture = null; }); } ``` ChatScreen warm-up + `userTurn` lazy load 가 race 해도 native init 1회만 실행. `_doLoad` throw 시 `whenComplete` 가 `_loadingFuture = null` 처리 → 다음 caller 가 새 시도. ## UI binding (`chat_screen.dart`) - `TextField.enabled = !state.isStreaming` — warmup state 와 무관 (타이핑 미리 작성 가능, UX R1+R2). - `hintText` 분기 — warmup loading 시 `AI 준비 중… 첫 시작은 몇 초 걸려요`, 그 외 평상. - send 영역 — `state.isStreaming || isWarming` 이면 `CircularProgressIndicator(strokeWidth: 2)`, 그 외 `IconButton.filled`. - `_WarmupErrorBanner` (`Failed` 일 때만 표시): - 메시지 본문 = 상태 기술만 (명령형 X, AC12). - 우측 정렬 `OutlinedButton`: - `fileMissing` → `[설정으로 가기]` + `Navigator.push(SettingsScreen).then((_) => retry())` (pop 후 자동 retry). - `runtime` → `[다시 시도]` + `retry()` (즉시). ## 마이크로카피 | 상황 | 한국어 | |------|--------| | warmup 중 hintText | `AI 준비 중… 첫 시작은 몇 초 걸려요` | | 평상 hintText | `습관 추가, 기록, 카탈로그 질문…` | | Failed(fileMissing) | `AI 모델 파일을 찾을 수 없어요.` | | Failed(runtime) | `AI 를 시작하지 못했어요.` | 명령형 ("다시 시도해주세요" 등) 은 본문 금지 — 행동은 버튼이 담당. ## 테스트 - `app/test/state/chat_warmup_test.dart` — 8 unit (AC1-2, 5-7, 11-12 + retry + 재진입 가드). - `app/test/data/ai/model_lifecycle_test.dart` — quickCheck 4 신규. - `app/test/ui/chat_screen_test.dart` — widget E2E 보류 (NOTE comment 사유). `CircularProgressIndicator` 무한 ticker ↔ `pumpAndSettle` race 가 framework-level 한계. ## 관련 문서 - 설계서: [docs/design/311-llm-warmup/](../design/311-llm-warmup/) - 사용자 가이드: [AI 코치와 대화하기](../guides/ai-chat-using.md) - 변경 이력: [CHANGELOG.md](../../CHANGELOG.md) [0.4.1]