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>
225 lines
6.3 KiB
Dart
225 lines
6.3 KiB
Dart
import '../../ai/tools/tool_definition.dart';
|
|
|
|
/// Abstract LLM backend.
|
|
///
|
|
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
|
/// Contract:
|
|
/// - After [load], [isLoaded] is true.
|
|
/// - [generateStructured] throws [StateError] when not loaded.
|
|
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
|
/// On schema/parse failure throws [FormatException].
|
|
/// - [unload] is idempotent.
|
|
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
|
|
abstract class LlmService {
|
|
bool get isLoaded;
|
|
|
|
Future<void> load();
|
|
|
|
Future<void> unload();
|
|
|
|
/// Calls the model with a function-calling [schema] and returns the parsed
|
|
/// JSON arguments map. Caller is responsible for applying `.timeout(...)`.
|
|
Future<Map<String, dynamic>> generateStructured(
|
|
String prompt,
|
|
Map<String, dynamic> schema,
|
|
);
|
|
|
|
/// Opens a chat session that supports multi-turn user input + tool result
|
|
/// submission with the supplied [tools]. See ADR-0005.
|
|
Future<LlmChatSession> startChat({
|
|
required List<ToolDefinition> tools,
|
|
});
|
|
}
|
|
|
|
/// Streaming chat session for the tool-calling loop.
|
|
///
|
|
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
|
|
/// screen, must be [close]d when the user dismisses the screen. Each
|
|
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
|
|
/// control (text done or a function call requested).
|
|
abstract class LlmChatSession {
|
|
Stream<LlmChatEvent> sendUser(String text);
|
|
|
|
Stream<LlmChatEvent> sendToolResult({
|
|
required String toolName,
|
|
required Map<String, dynamic> result,
|
|
});
|
|
|
|
Future<void> close();
|
|
}
|
|
|
|
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
|
|
sealed class LlmChatEvent {
|
|
const LlmChatEvent();
|
|
}
|
|
|
|
final class LlmTextChunk extends LlmChatEvent {
|
|
final String text;
|
|
const LlmTextChunk(this.text);
|
|
}
|
|
|
|
final class LlmFunctionCall extends LlmChatEvent {
|
|
final String name;
|
|
final Map<String, dynamic> args;
|
|
const LlmFunctionCall(this.name, this.args);
|
|
}
|
|
|
|
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
|
class MockLlmService implements LlmService {
|
|
final List<_Response> _queue = [];
|
|
bool _loaded = false;
|
|
int callCount = 0;
|
|
String? lastPrompt;
|
|
Map<String, dynamic>? lastSchema;
|
|
Duration responseDelay = Duration.zero;
|
|
|
|
/// #311 test helpers. Simulate cold-load latency / failure so the warm-up
|
|
/// controller can be exercised. Mirrors the Gemma path:
|
|
/// - `loadDelay > 0` → load completes after the delay
|
|
/// - `loadThrows` → load throws this error
|
|
/// - `loadCount` → observed by concurrent-load tests
|
|
Duration loadDelay = Duration.zero;
|
|
Object? loadThrows;
|
|
int loadCount = 0;
|
|
Future<void>? _loadingFuture;
|
|
|
|
/// Queues consumed by [startChat] in order. Each entry is the event list
|
|
/// returned for a single `send*` call.
|
|
final List<List<LlmChatEvent>> chatScript = [];
|
|
int chatStartCount = 0;
|
|
MockLlmChatSession? lastChat;
|
|
|
|
@override
|
|
bool get isLoaded => _loaded;
|
|
|
|
/// #311 AC7: same concurrent-call guard as [GemmaLlmService]. Repeated
|
|
/// in-flight `load()` calls share a single Future, so test assertions on
|
|
/// `loadCount` reflect the number of native-init attempts (1), not the
|
|
/// number of callers.
|
|
@override
|
|
Future<void> load() {
|
|
if (_loaded) return Future.value();
|
|
final existing = _loadingFuture;
|
|
if (existing != null) return existing;
|
|
final future = _doLoad();
|
|
_loadingFuture = future;
|
|
return future.whenComplete(() {
|
|
_loadingFuture = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _doLoad() async {
|
|
loadCount += 1;
|
|
if (loadDelay > Duration.zero) {
|
|
await Future<void>.delayed(loadDelay);
|
|
}
|
|
final err = loadThrows;
|
|
if (err != null) throw err;
|
|
_loaded = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> unload() async {
|
|
_loaded = false;
|
|
}
|
|
|
|
void enqueueResponse(Map<String, dynamic> response) {
|
|
_queue.add(_Response.value(response));
|
|
}
|
|
|
|
void enqueueError(Object error) {
|
|
_queue.add(_Response.error(error));
|
|
}
|
|
|
|
/// Enqueue one batch of events that will be emitted on the next
|
|
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
|
|
void enqueueChatEvents(List<LlmChatEvent> events) {
|
|
chatScript.add(events);
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> generateStructured(
|
|
String prompt,
|
|
Map<String, dynamic> schema,
|
|
) async {
|
|
callCount += 1;
|
|
lastPrompt = prompt;
|
|
lastSchema = schema;
|
|
if (!_loaded) {
|
|
throw StateError('LlmService not loaded');
|
|
}
|
|
if (responseDelay > Duration.zero) {
|
|
await Future<void>.delayed(responseDelay);
|
|
}
|
|
if (_queue.isEmpty) {
|
|
throw StateError('MockLlmService: no queued response');
|
|
}
|
|
final r = _queue.removeAt(0);
|
|
if (r.error != null) throw r.error!;
|
|
return r.value!;
|
|
}
|
|
|
|
@override
|
|
Future<LlmChatSession> startChat({
|
|
required List<ToolDefinition> tools,
|
|
}) async {
|
|
if (!_loaded) {
|
|
throw StateError('LlmService not loaded');
|
|
}
|
|
chatStartCount += 1;
|
|
final session = MockLlmChatSession(chatScript);
|
|
lastChat = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
/// Mock chat session that replays pre-queued events from [MockLlmService].
|
|
class MockLlmChatSession implements LlmChatSession {
|
|
final List<List<LlmChatEvent>> _script;
|
|
int sendCount = 0;
|
|
final List<String> userInputs = [];
|
|
final List<(String, Map<String, dynamic>)> toolResults = [];
|
|
bool closed = false;
|
|
|
|
MockLlmChatSession(this._script);
|
|
|
|
Stream<LlmChatEvent> _emitNext() async* {
|
|
sendCount += 1;
|
|
if (_script.isEmpty) {
|
|
throw StateError('MockLlmChatSession: no queued events');
|
|
}
|
|
final batch = _script.removeAt(0);
|
|
for (final ev in batch) {
|
|
yield ev;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Stream<LlmChatEvent> sendUser(String text) {
|
|
userInputs.add(text);
|
|
return _emitNext();
|
|
}
|
|
|
|
@override
|
|
Stream<LlmChatEvent> sendToolResult({
|
|
required String toolName,
|
|
required Map<String, dynamic> result,
|
|
}) {
|
|
toolResults.add((toolName, result));
|
|
return _emitNext();
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
closed = true;
|
|
}
|
|
}
|
|
|
|
class _Response {
|
|
final Map<String, dynamic>? value;
|
|
final Object? error;
|
|
const _Response._(this.value, this.error);
|
|
factory _Response.value(Map<String, dynamic> v) => _Response._(v, null);
|
|
factory _Response.error(Object e) => _Response._(null, e);
|
|
}
|