Files
life-helper/app/lib/main.dart
joungmin b1bed4d5ca [03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 +
habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate
for destructive ops, multi-turn LlmChatSession abstraction wired to
flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with
MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI
opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope,
catalog/tracker/habit tools, dispatcher, controller loop) — 151 total
passing.

Refs #260
2026-06-15 10:42:43 +09:00

104 lines
3.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'ai/tools/tool_definition.dart' as tools;
import 'data/ai/gemma_llm_service.dart';
import 'data/ai/llm_service.dart';
import 'data/ai/model_lifecycle.dart';
import 'data/db/daos/meta_dao.dart';
import 'state/ai_providers.dart';
import 'state/providers.dart';
import 'ui/screens/habit_list_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openProductionDatabase();
runApp(ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(db),
// #218: real GemmaLlmService when model file is on disk + verified,
// MockLlmService otherwise. The provider is read lazily by the frame
// suggestion flow, so the resolution is dynamic per call.
llmServiceProvider.overrideWith((ref) {
return _LazyLlmService(
lifecycle: ref.watch(modelLifecycleProvider),
meta: ref.watch(metaDaoProvider),
);
}),
],
child: const LifeHelperApp(),
));
}
/// Adapter that lazily resolves between [GemmaLlmService] (when the
/// model file exists + meta is intact) and [MockLlmService] (fallback,
/// graceful empty candidates). Keeps the rest of the app unaware of
/// the difference — `suggestFrame` only sees [LlmService].
class _LazyLlmService implements LlmService {
_LazyLlmService({required this.lifecycle, required this.meta});
final ModelLifecycle lifecycle;
final MetaDao meta;
LlmService? _delegate;
Future<LlmService> _resolve() async {
final avail = await lifecycle.checkAvailability();
final path = await meta.find(AiMetaKeys.modelPath);
final wantGemma = avail == ModelAvailability.ready && path != null;
// Re-resolve every call so opt-in / opt-out state changes are reflected
// without an app restart. Repeat-resolve of the same kind reuses the
// cached instance (Gemma's flutter_gemma installModel is idempotent;
// Mock has no setup), but the kind itself flips when availability does.
final keep = _delegate != null &&
(wantGemma == (_delegate is GemmaLlmService)) &&
(!wantGemma ||
(_delegate as GemmaLlmService).modelPath == path);
if (!keep) {
_delegate = wantGemma
? GemmaLlmService(modelPath: path)
: MockLlmService();
}
return _delegate!;
}
@override
bool get isLoaded => _delegate?.isLoaded ?? false;
@override
Future<void> load() async => (await _resolve()).load();
@override
Future<void> unload() async {
final d = _delegate;
if (d != null) await d.unload();
}
@override
Future<Map<String, dynamic>> generateStructured(
String prompt,
Map<String, dynamic> schema,
) async =>
(await _resolve()).generateStructured(prompt, schema);
@override
Future<LlmChatSession> startChat({
required List<tools.ToolDefinition> tools,
}) async =>
(await _resolve()).startChat(tools: tools);
}
class LifeHelperApp extends StatelessWidget {
const LifeHelperApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'life-helper',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const HabitListScreen(),
);
}
}