[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:
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../ai/tools/tool_envelope.dart';
|
||||
import '../../state/chat_providers.dart';
|
||||
import '../../state/chat_warmup_provider.dart';
|
||||
|
||||
/// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 +
|
||||
/// in-process tool runtime. ConfirmGate modals appear on destructive
|
||||
@@ -18,6 +19,18 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// #311 AC1: ChatScreen mount → background warm-up. depsAsync.data 가
|
||||
// resolve 되기 전에는 toolDepsProvider 도 미준비라 send 자체가 막혀
|
||||
// 있으므로, 그 사이에 native init 만 먼저 끝낸다.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(chatWarmupProvider.notifier).start();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textCtrl.dispose();
|
||||
@@ -72,18 +85,33 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final state = ref.watch(chatSessionControllerProvider);
|
||||
final warmup = ref.watch(chatWarmupProvider);
|
||||
_scrollToBottom();
|
||||
|
||||
// #311 AC3 / UX R3: warmup 중에는 hintText 만 교체. 입력창 자체는
|
||||
// enabled (사용자가 미리 타이핑 가능 — AC9).
|
||||
final isWarming = warmup is ChatWarmupLoading;
|
||||
final hintText = isWarming
|
||||
? 'AI 준비 중… 첫 시작은 몇 초 걸려요'
|
||||
: '습관 추가, 기록, 카탈로그 질문…';
|
||||
|
||||
// AC10: warmup ready 이고 streaming 중이 아닐 때 send 활성. 빈 텍스트는
|
||||
// _send() 가 early-return 하므로 별도 gating 불필요 (rebuild race 회피).
|
||||
final canSend = !state.isStreaming && !isWarming;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (warmup is ChatWarmupFailed) _WarmupErrorBanner(warmup: warmup),
|
||||
if (state.error != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
color: theme.colorScheme.errorContainer,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -117,31 +145,32 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
enabled: !state.isStreaming,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '습관 추가, 기록, 카탈로그 질문…',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
onSubmitted: (_) => canSend ? _send() : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
state.isStreaming
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: IconButton.filled(
|
||||
onPressed: _send,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
if (state.isStreaming || isWarming)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filled(
|
||||
onPressed: canSend ? _send : null,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -150,6 +179,41 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// #311 AC5 / UX R5+R6: 실패 메시지는 상태만 기술, 행동은 [다시 시도] 버튼.
|
||||
class _WarmupErrorBanner extends ConsumerWidget {
|
||||
final ChatWarmupFailed warmup;
|
||||
const _WarmupErrorBanner({required this.warmup});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: theme.colorScheme.errorContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
warmup.message,
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
ref.read(chatWarmupProvider.notifier).retry();
|
||||
},
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly Korean labels for the 6 tools registered in
|
||||
/// `ToolRegistry.defaults()`. Falls back to the raw tool name for any
|
||||
/// future tool that hasn't been mapped yet — better to show the raw id
|
||||
|
||||
Reference in New Issue
Block a user