637 lines
20 KiB
Dart
637 lines
20 KiB
Dart
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.)
|