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>
136 lines
4.2 KiB
Dart
136 lines
4.2 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../data/ai/llm_service.dart';
|
|
import '../data/ai/model_lifecycle.dart';
|
|
import 'ai_providers.dart';
|
|
|
|
/// State machine for ChatScreen LLM warm-up (#311).
|
|
///
|
|
/// See `docs/design/311-llm-warmup/README.md` §6 / fn-chat_warmup_controller.md.
|
|
sealed class ChatWarmupState {
|
|
const ChatWarmupState();
|
|
}
|
|
|
|
final class ChatWarmupIdle extends ChatWarmupState {
|
|
const ChatWarmupIdle();
|
|
}
|
|
|
|
final class ChatWarmupLoading extends ChatWarmupState {
|
|
const ChatWarmupLoading();
|
|
}
|
|
|
|
final class ChatWarmupReady extends ChatWarmupState {
|
|
const ChatWarmupReady();
|
|
}
|
|
|
|
/// Warm-up was not attempted because [ModelLifecycle.quickCheck] returned
|
|
/// something other than `ready` (download incomplete, opt-out, corrupt).
|
|
/// UI behaves as if warm-up didn't exist; the first user send falls back to
|
|
/// the existing lazy `userTurn` path.
|
|
final class ChatWarmupUnavailable extends ChatWarmupState {
|
|
const ChatWarmupUnavailable();
|
|
}
|
|
|
|
/// `kind` discriminates the retry copy: `fileMissing` is a settings-level
|
|
/// recovery; `runtime` is a transient retry.
|
|
enum ChatWarmupFailureKind { fileMissing, runtime }
|
|
|
|
final class ChatWarmupFailed extends ChatWarmupState {
|
|
final String message;
|
|
final ChatWarmupFailureKind kind;
|
|
const ChatWarmupFailed(this.message, this.kind);
|
|
}
|
|
|
|
/// Drives `LlmService.load()` on ChatScreen mount so the first user send
|
|
/// doesn't pay native-init latency. AC1-AC12 (12개) 모두 본 controller 가
|
|
/// 흡수한다 (UI binding 은 chat_screen.dart 가 본 state 를 watch).
|
|
class ChatWarmupController extends StateNotifier<ChatWarmupState> {
|
|
ChatWarmupController({
|
|
required this.llm,
|
|
required this.lifecycle,
|
|
}) : super(const ChatWarmupIdle());
|
|
|
|
final LlmService llm;
|
|
final ModelLifecycle lifecycle;
|
|
bool _disposed = false;
|
|
|
|
/// Idempotent. Re-entrant guard via the Loading state — duplicate `start`
|
|
/// calls during an in-flight load do nothing (the running future will set
|
|
/// the final state). External callers use [retry] instead.
|
|
Future<void> start() async {
|
|
if (state is ChatWarmupLoading) return;
|
|
|
|
// AC11 / UX R4: fast path. Skip Loading entirely if the underlying
|
|
// service is already loaded — prevents 1-frame label flicker on
|
|
// ChatScreen re-entry.
|
|
if (llm.isLoaded) {
|
|
_safeSet(const ChatWarmupReady());
|
|
return;
|
|
}
|
|
|
|
final availability = await lifecycle.quickCheck();
|
|
if (_disposed) return;
|
|
if (availability != ModelAvailability.ready) {
|
|
_safeSet(const ChatWarmupUnavailable());
|
|
return;
|
|
}
|
|
|
|
_safeSet(const ChatWarmupLoading());
|
|
try {
|
|
await llm.load();
|
|
} catch (e) {
|
|
if (_disposed) return;
|
|
final kind = e is FileSystemException
|
|
? ChatWarmupFailureKind.fileMissing
|
|
: ChatWarmupFailureKind.runtime;
|
|
_safeSet(ChatWarmupFailed(_messageFor(kind), kind));
|
|
return;
|
|
}
|
|
if (_disposed) return;
|
|
_safeSet(const ChatWarmupReady());
|
|
}
|
|
|
|
Future<void> retry() async {
|
|
if (_disposed) return;
|
|
_safeSet(const ChatWarmupIdle());
|
|
await start();
|
|
}
|
|
|
|
/// AC6 / AC12: state는 disposed 인스턴스에는 더 이상 쓰지 않는다.
|
|
/// StateNotifier 의 setter 는 disposed 시 throw 하므로 가드 필수.
|
|
void _safeSet(ChatWarmupState s) {
|
|
if (_disposed) return;
|
|
state = s;
|
|
}
|
|
|
|
/// UX R5 / AC12: 메시지는 **상태**만 기술. "다시 시도해주세요" 같은
|
|
/// 명령형은 [다시 시도] 버튼이 담당하므로 본 문안에 넣지 않는다.
|
|
String _messageFor(ChatWarmupFailureKind kind) {
|
|
switch (kind) {
|
|
case ChatWarmupFailureKind.fileMissing:
|
|
return 'AI 모델 파일을 찾을 수 없어요.';
|
|
case ChatWarmupFailureKind.runtime:
|
|
return 'AI 를 시작하지 못했어요.';
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// autoDispose: ChatScreen 이 pop 되면 controller 도 dispose → mount race 안전.
|
|
final chatWarmupProvider =
|
|
StateNotifierProvider.autoDispose<ChatWarmupController, ChatWarmupState>(
|
|
(ref) {
|
|
return ChatWarmupController(
|
|
llm: ref.watch(llmServiceProvider),
|
|
lifecycle: ref.watch(modelLifecycleProvider),
|
|
);
|
|
},
|
|
);
|