[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
This commit is contained in:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../ai/tools/tool_envelope.dart';
import '../../state/chat_providers.dart';
/// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 +
/// in-process tool runtime. ConfirmGate modals appear on destructive
/// tool calls (add_habit, log_tracker_entry).
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key});
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollCtrl.hasClients) return;
_scrollCtrl.animateTo(
_scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
Future<void> _send() async {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
_textCtrl.clear();
await ref
.read(chatSessionControllerProvider.notifier)
.userTurn(text, context);
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
final depsAsync = ref.watch(toolDepsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('AI 코치'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '새 대화',
onPressed: () {
ref.read(chatSessionControllerProvider.notifier).clear();
},
),
],
),
body: depsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('초기화 실패: $e')),
data: (_) => _buildBody(context),
),
);
}
Widget _buildBody(BuildContext context) {
final state = ref.watch(chatSessionControllerProvider);
_scrollToBottom();
return Column(
children: [
if (state.error != null)
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.errorContainer,
padding: const EdgeInsets.all(12),
child: Text(
state.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
Expanded(
child: ListView.builder(
controller: _scrollCtrl,
padding: const EdgeInsets.all(12),
itemCount: state.messages.length +
(state.streamingText != null &&
state.streamingText!.isNotEmpty
? 1
: 0),
itemBuilder: (context, i) {
if (i < state.messages.length) {
return _MessageBubble(message: state.messages[i]);
}
return _MessageBubble(
message: ModelChatMessage(state.streamingText ?? ''),
streaming: true,
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _textCtrl,
enabled: !state.isStreaming,
decoration: const InputDecoration(
hintText: '습관 추가, 기록, 카탈로그 질문…',
border: OutlineInputBorder(),
isDense: true,
),
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
),
),
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),
),
],
),
),
],
);
}
}
class _MessageBubble extends StatelessWidget {
final ChatMessage message;
final bool streaming;
const _MessageBubble({required this.message, this.streaming = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
switch (message) {
case UserChatMessage m:
return _bubble(
context,
align: Alignment.centerRight,
color: theme.colorScheme.primaryContainer,
textColor: theme.colorScheme.onPrimaryContainer,
text: m.text,
);
case ModelChatMessage m:
return _bubble(
context,
align: Alignment.centerLeft,
color: theme.colorScheme.surfaceContainerHighest,
textColor: theme.colorScheme.onSurface,
text: m.text + (streaming ? '' : ''),
);
case ToolCallChatMessage m:
return Align(
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
'🛠 ${m.name}${_toolResultLabel(m.result)}',
style: theme.textTheme.bodySmall,
),
),
);
case SystemChatMessage m:
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
m.text,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
);
}
}
Widget _bubble(
BuildContext context, {
required Alignment align,
required Color color,
required Color textColor,
required String text,
}) {
return Align(
alignment: align,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Text(text, style: TextStyle(color: textColor)),
),
);
}
String _toolResultLabel(ToolResult r) {
switch (r) {
case ToolOk _:
return 'OK';
case ToolErr e:
return '오류: ${e.code}';
case ToolCancelled _:
return '취소됨';
}
}
}