[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:
243
app/lib/ui/screens/chat_screen.dart
Normal file
243
app/lib/ui/screens/chat_screen.dart
Normal 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 '취소됨';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user