it0/it0_app/lib/features/chat/presentation/pages/chat_page.dart

637 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/network/websocket_client.dart';
import '../../../../core/theme/app_colors.dart';
// ---------------------------------------------------------------------------
// TODO 39 & 40 Chat page: message send + voice input
// ---------------------------------------------------------------------------
// ---- Chat message model ----------------------------------------------------
enum ChatRole { user, assistant, system }
enum ChatContentType { text, toolUse }
class ChatMessage {
final String id;
final ChatRole role;
final ChatContentType contentType;
final String content;
final DateTime timestamp;
const ChatMessage({
required this.id,
required this.role,
required this.contentType,
required this.content,
required this.timestamp,
});
}
// ---- Chat state notifier ---------------------------------------------------
class ChatState {
final List<ChatMessage> messages;
final bool isStreaming;
final String? sessionId;
final String? error;
const ChatState({
this.messages = const [],
this.isStreaming = false,
this.sessionId,
this.error,
});
ChatState copyWith({
List<ChatMessage>? messages,
bool? isStreaming,
String? sessionId,
String? error,
}) {
return ChatState(
messages: messages ?? this.messages,
isStreaming: isStreaming ?? this.isStreaming,
sessionId: sessionId ?? this.sessionId,
error: error,
);
}
}
class ChatNotifier extends StateNotifier<ChatState> {
final Ref _ref;
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
ChatNotifier(this._ref) : super(const ChatState());
/// Sends a user prompt, creates a task via the agent endpoint, subscribes to
/// the session WebSocket, and streams response messages into the chat.
Future<void> sendMessage(String prompt) async {
if (prompt.trim().isEmpty) return;
// Add the user message locally
final userMsg = ChatMessage(
id: DateTime.now().microsecondsSinceEpoch.toString(),
role: ChatRole.user,
contentType: ChatContentType.text,
content: prompt,
timestamp: DateTime.now(),
);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
error: null,
);
try {
// POST to agent tasks endpoint
final dio = _ref.read(dioClientProvider);
final response = await dio.post(
ApiEndpoints.tasks,
data: {'prompt': prompt},
);
final data = response.data as Map<String, dynamic>;
final sessionId = data['sessionId'] as String? ?? data['session_id'] as String?;
final taskId = data['taskId'] as String? ?? data['task_id'] as String?;
if (sessionId == null) {
state = state.copyWith(isStreaming: false, error: 'No sessionId returned');
return;
}
state = state.copyWith(sessionId: sessionId);
// Connect to agent WebSocket and subscribe
final wsClient = _ref.read(webSocketClientProvider);
await wsClient.connect('/ws/agent');
wsClient.send({
'event': 'subscribe_session',
'data': {'sessionId': sessionId, 'taskId': taskId},
});
// Listen for stream_event messages
_wsSubscription?.cancel();
_wsSubscription = wsClient.messages.listen((msg) {
final event = msg['event'] as String? ?? msg['type'] as String? ?? '';
if (event == 'stream_event' || event == 'message') {
_handleStreamEvent(msg['data'] as Map<String, dynamic>? ?? msg);
} else if (event == 'stream_end' || event == 'done' || event == 'complete') {
state = state.copyWith(isStreaming: false);
_wsSubscription?.cancel();
} else if (event == 'error') {
state = state.copyWith(
isStreaming: false,
error: msg['message'] as String? ?? 'Stream error',
);
_wsSubscription?.cancel();
}
});
} catch (e) {
state = state.copyWith(isStreaming: false, error: e.toString());
}
}
void _handleStreamEvent(Map<String, dynamic> data) {
final type = data['type'] as String? ?? 'text';
final content = data['content'] as String? ?? data['text'] as String? ?? '';
final ChatContentType contentType;
if (type == 'tool_use' || type == 'tool_call') {
contentType = ChatContentType.toolUse;
} else {
contentType = ChatContentType.text;
}
// If the last message is an assistant text message that is still streaming,
// append to it instead of creating a new bubble.
if (contentType == ChatContentType.text && state.messages.isNotEmpty) {
final last = state.messages.last;
if (last.role == ChatRole.assistant && last.contentType == ChatContentType.text) {
final updated = ChatMessage(
id: last.id,
role: ChatRole.assistant,
contentType: ChatContentType.text,
content: last.content + content,
timestamp: last.timestamp,
);
state = state.copyWith(
messages: [...state.messages.sublist(0, state.messages.length - 1), updated],
);
return;
}
}
final msg = ChatMessage(
id: DateTime.now().microsecondsSinceEpoch.toString(),
role: ChatRole.assistant,
contentType: contentType,
content: content,
timestamp: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, msg]);
}
void clearChat() {
_wsSubscription?.cancel();
state = const ChatState();
}
@override
void dispose() {
_wsSubscription?.cancel();
super.dispose();
}
}
final chatMessagesProvider =
StateNotifierProvider<ChatNotifier, ChatState>((ref) {
return ChatNotifier(ref);
});
// ---------------------------------------------------------------------------
// Chat page ConsumerStatefulWidget
// ---------------------------------------------------------------------------
class ChatPage extends ConsumerStatefulWidget {
const ChatPage({super.key});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderStateMixin {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
// -- Voice input (TODO 40) ------------------------------------------------
late final stt.SpeechToText _speech;
bool _speechAvailable = false;
bool _isListening = false;
late AnimationController _micPulseController;
@override
void initState() {
super.initState();
_speech = stt.SpeechToText();
_initSpeech();
_micPulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
}
Future<void> _initSpeech() async {
_speechAvailable = await _speech.initialize(
onStatus: (status) {
if (status == 'done' || status == 'notListening') {
_stopListening(autoSubmit: true);
}
},
onError: (_) => _stopListening(),
);
if (mounted) setState(() {});
}
void _startListening() {
if (!_speechAvailable) return;
setState(() => _isListening = true);
_micPulseController.repeat(reverse: true);
_speech.listen(
onResult: (result) {
_messageController.text = result.recognizedWords;
if (result.finalResult) {
_stopListening(autoSubmit: true);
}
},
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 3),
);
}
void _stopListening({bool autoSubmit = false}) {
_speech.stop();
_micPulseController.stop();
_micPulseController.reset();
if (!mounted) return;
setState(() => _isListening = false);
if (autoSubmit && _messageController.text.trim().isNotEmpty) {
_send();
}
}
// -- Send ------------------------------------------------------------------
void _send() {
final text = _messageController.text.trim();
if (text.isEmpty) return;
_messageController.clear();
ref.read(chatMessagesProvider.notifier).sendMessage(text);
_scrollToBottom();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
// -- Build -----------------------------------------------------------------
@override
Widget build(BuildContext context) {
final chatState = ref.watch(chatMessagesProvider);
// Auto-scroll when messages change
ref.listen(chatMessagesProvider, (_, __) => _scrollToBottom());
return Scaffold(
appBar: AppBar(
title: const Text('AI Agent'),
actions: [
if (chatState.messages.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Clear chat',
onPressed: () => ref.read(chatMessagesProvider.notifier).clearChat(),
),
// Voice input button (TODO 40)
GestureDetector(
onLongPressStart: (_) => _startListening(),
onLongPressEnd: (_) => _stopListening(autoSubmit: true),
child: AnimatedBuilder(
animation: _micPulseController,
builder: (context, child) {
return IconButton(
icon: Icon(
_isListening ? Icons.mic : Icons.mic_none,
color: _isListening
? Color.lerp(AppColors.error, AppColors.warning, _micPulseController.value)
: null,
size: _isListening ? 28 + (_micPulseController.value * 4) : 24,
),
onPressed: () {
if (_isListening) {
_stopListening(autoSubmit: true);
} else {
_startListening();
}
},
);
},
),
),
],
),
body: Column(
children: [
// Error banner
if (chatState.error != null)
MaterialBanner(
content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)),
backgroundColor: AppColors.error.withOpacity(0.1),
actions: [
TextButton(
onPressed: () =>
ref.read(chatMessagesProvider.notifier).clearChat(),
child: const Text('Dismiss'),
),
],
),
// Message list
Expanded(
child: chatState.messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
const SizedBox(height: 16),
Text(
'Start a conversation with iAgent',
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Hold the mic button for voice input',
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
itemCount: chatState.messages.length + (chatState.isStreaming ? 1 : 0),
itemBuilder: (context, index) {
if (index == chatState.messages.length) {
// Typing indicator
return _TypingIndicator();
}
return _ChatBubble(message: chatState.messages[index]);
},
),
),
// Voice listening indicator
if (_isListening)
Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: AppColors.error.withOpacity(0.1),
child: Row(
children: [
AnimatedBuilder(
animation: _micPulseController,
builder: (context, _) => Icon(
Icons.mic,
color: AppColors.error,
size: 20 + (_micPulseController.value * 4),
),
),
const SizedBox(width: 8),
const Text('Listening...', style: TextStyle(color: AppColors.error)),
const Spacer(),
TextButton(
onPressed: () => _stopListening(),
child: const Text('Cancel'),
),
],
),
),
// Input row
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Ask iAgent...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
enabled: !chatState.isStreaming,
),
),
const SizedBox(width: 8),
IconButton(
icon: chatState.isStreaming
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
onPressed: chatState.isStreaming ? null : _send,
),
],
),
),
),
],
),
);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_micPulseController.dispose();
_speech.stop();
super.dispose();
}
}
// ---------------------------------------------------------------------------
// Private helper widgets
// ---------------------------------------------------------------------------
class _ChatBubble extends StatelessWidget {
final ChatMessage message;
const _ChatBubble({required this.message});
bool get _isUser => message.role == ChatRole.user;
@override
Widget build(BuildContext context) {
if (message.contentType == ChatContentType.toolUse) {
return _ToolUseCard(content: message.content);
}
return Align(
alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: _isUser ? AppColors.primary : AppColors.surface,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(_isUser ? 16 : 4),
bottomRight: Radius.circular(_isUser ? 4 : 16),
),
),
child: Text(
message.content,
style: TextStyle(
color: _isUser ? Colors.white : AppColors.textPrimary,
fontSize: 15,
),
),
),
);
}
}
class _ToolUseCard extends StatelessWidget {
final String content;
const _ToolUseCard({required this.content});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceLight.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.primary.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.code, size: 16, color: AppColors.primary),
const SizedBox(width: 6),
Text(
'Tool Execution',
style: TextStyle(
color: AppColors.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
content,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
],
),
);
}
}
class _TypingIndicator extends StatefulWidget {
@override
State<_TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<_TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final delay = i * 0.33;
final t = ((_controller.value + delay) % 1.0);
final opacity = (t < 0.5) ? 0.3 + t * 1.0 : 1.3 - t * 1.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Opacity(
opacity: opacity.clamp(0.3, 1.0),
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.textSecondary,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
),
),
);
}
}
/// A mini [AnimatedWidget] wrapper used in lieu of [AnimatedBuilder].
/// Flutter's actual class is [AnimatedBuilder]; we re-export via this alias
/// so that the code reads naturally and works with the stable API.
/// (AnimatedBuilder is the stable name since Flutter 3.x.)