import 'dart:async'; import 'dart:typed_data'; import 'package:dio/dio.dart' show FormData, MultipartFile; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:record/record.dart'; import '../../../../core/audio/noise_reducer.dart'; import '../../../../core/audio/speech_enhancer.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../domain/entities/chat_message.dart'; import '../providers/chat_providers.dart'; import '../widgets/message_bubble.dart'; import '../widgets/tool_execution_card.dart'; import '../widgets/approval_action_card.dart'; import '../widgets/agent_thinking_indicator.dart'; import '../widgets/stream_text_widget.dart'; // --------------------------------------------------------------------------- // Chat page – ConsumerStatefulWidget // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState with SingleTickerProviderStateMixin { final _messageController = TextEditingController(); final _scrollController = ScrollController(); // -- Voice input (record + GTCRN denoise + backend STT) ------------------- late final AudioRecorder _recorder; final SpeechEnhancer _enhancer = SpeechEnhancer(); bool _isListening = false; bool _isTranscribing = false; List> _audioChunks = []; StreamSubscription>? _audioSubscription; late AnimationController _micPulseController; @override void initState() { super.initState(); _recorder = AudioRecorder(); _enhancer.init(); // load GTCRN model in background _micPulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); } Future _startListening() async { final hasPermission = await _recorder.hasPermission(); if (!hasPermission || !mounted) return; setState(() => _isListening = true); _micPulseController.repeat(reverse: true); _audioChunks = []; // Stream raw PCM 16kHz mono with platform noise suppression + AGC final stream = await _recorder.startStream(const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 16000, numChannels: 1, noiseSuppress: true, autoGain: true, )); _audioSubscription = stream.listen((data) { _audioChunks.add(data); }); } Future _stopListening({bool autoSubmit = false}) async { if (!_isListening) return; // Stop recording and stream await _recorder.stop(); await _audioSubscription?.cancel(); _audioSubscription = null; _micPulseController.stop(); _micPulseController.reset(); if (!mounted) return; setState(() => _isListening = false); if (!autoSubmit || _audioChunks.isEmpty) return; // Transcribe via backend setState(() => _isTranscribing = true); try { // Combine recorded chunks into a single PCM buffer final allBytes = _audioChunks.expand((c) => c).toList(); final pcmData = Uint8List.fromList(allBytes); _audioChunks = []; // GTCRN ML denoise (light) + trim leading/trailing silence final denoised = _enhancer.enhance(pcmData); final trimmed = NoiseReducer.trimSilence(denoised); if (trimmed.isEmpty) { if (mounted) setState(() => _isTranscribing = false); return; } // POST to backend /voice/transcribe final dio = ref.read(dioClientProvider); final formData = FormData.fromMap({ 'audio': MultipartFile.fromBytes(trimmed, filename: 'audio.pcm'), }); final response = await dio.post( ApiEndpoints.transcribe, data: formData, ); final text = (response.data as Map)['text'] as String? ?? ''; if (text.isNotEmpty && mounted) { _messageController.text = text; _send(); } } catch (_) { // Voice failed silently – user can still type } finally { if (mounted) setState(() => _isTranscribing = false); } } // -- Send ------------------------------------------------------------------ void _send() { final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); ref.read(chatProvider.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, ); } }); } // -- Message widget dispatch ----------------------------------------------- Widget _buildMessageWidget(ChatMessage message, ChatState chatState) { final isLastMessage = chatState.messages.last.id == message.id; final isStreamingNow = isLastMessage && chatState.isStreaming; switch (message.type) { case MessageType.toolUse: case MessageType.toolResult: if (message.toolExecution != null) { return ToolExecutionCard(toolExecution: message.toolExecution!); } return MessageBubble(message: message); case MessageType.approval: if (message.approvalRequest != null) { return ApprovalActionCard( approvalRequest: message.approvalRequest!, onApprove: () => ref.read(chatProvider.notifier) .approveCommand(message.approvalRequest!.taskId), onReject: (reason) => ref.read(chatProvider.notifier) .rejectCommand(message.approvalRequest!.taskId, reason: reason), ); } return MessageBubble(message: message); case MessageType.standingOrderDraft: return _StandingOrderDraftCard( draft: message.metadata ?? {}, onConfirm: () => ref.read(chatProvider.notifier) .confirmStandingOrder(message.metadata ?? {}), ); case MessageType.thinking: // AI thinking content — italic label + streaming text return Align( alignment: 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: AppColors.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '思考中...', style: TextStyle( color: AppColors.textMuted, fontSize: 11, fontStyle: FontStyle.italic, ), ), const SizedBox(height: 4), StreamTextWidget( text: message.content, isStreaming: isStreamingNow, style: TextStyle( color: AppColors.textSecondary, fontSize: 14, ), ), ], ), ), ); case MessageType.text: default: // User messages → standard bubble if (message.role == MessageRole.user) { return MessageBubble(message: message); } // AI text messages — use StreamTextWidget when actively streaming if (isStreamingNow) { return Align( alignment: 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: AppColors.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), ), child: StreamTextWidget( text: message.content, isStreaming: true, style: const TextStyle( color: AppColors.textPrimary, fontSize: 15, ), ), ), ); } return MessageBubble(message: message); } } // -- Build ----------------------------------------------------------------- @override Widget build(BuildContext context) { final chatState = ref.watch(chatProvider); // Auto-scroll when messages change ref.listen(chatProvider, (_, __) => _scrollToBottom()); return Scaffold( appBar: AppBar( title: const Text('AI 对话'), actions: [ // Stop button during streaming if (chatState.isStreaming) IconButton( icon: const Icon(Icons.stop_circle_outlined), tooltip: '停止', onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ), if (chatState.messages.isNotEmpty) IconButton( icon: const Icon(Icons.delete_outline), tooltip: '清空对话', onPressed: () => ref.read(chatProvider.notifier).clearChat(), ), // Voice input button 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(chatProvider.notifier).clearChat(), child: const Text('关闭'), ), ], ), // Agent status bar if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error) _AgentStatusBar(status: chatState.agentStatus), // 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( '开始与 iAgent 对话', style: TextStyle(color: AppColors.textSecondary, fontSize: 16), ), const SizedBox(height: 8), Text( '长按麦克风按钮进行语音输入', style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ], ), ) : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), itemCount: chatState.messages.length + (chatState.agentStatus == AgentStatus.thinking ? 1 : 0), itemBuilder: (context, index) { // Trailing thinking indicator if (index == chatState.messages.length) { return const AgentThinkingIndicator(); } return _buildMessageWidget( chatState.messages[index], chatState, ); }, ), ), // Voice listening / transcribing indicator if (_isListening || _isTranscribing) Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), color: (_isListening ? AppColors.error : AppColors.primary) .withOpacity(0.1), child: Row( children: [ if (_isListening) AnimatedBuilder( animation: _micPulseController, builder: (context, _) => Icon( Icons.mic, color: AppColors.error, size: 20 + (_micPulseController.value * 4), ), ) else const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text( _isListening ? '正在聆听...' : '正在转写...', style: TextStyle( color: _isListening ? AppColors.error : AppColors.primary, ), ), const Spacer(), if (_isListening) TextButton( onPressed: () => _stopListening(), child: const Text('取消'), ), ], ), ), // Input row _buildInputArea(chatState), ], ), ); } Widget _buildInputArea(ChatState chatState) { final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppColors.surface, border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (chatState.agentStatus == AgentStatus.awaitingApproval) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '等待审批中...', style: TextStyle(color: AppColors.warning, fontSize: 12), ), ), Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: const InputDecoration( hintText: '向 iAgent 提问...', border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(24)), ), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), enabled: !isDisabled, ), ), const SizedBox(width: 8), if (chatState.isStreaming) IconButton( icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error), tooltip: '停止', onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ) else IconButton( icon: const Icon(Icons.send), onPressed: isDisabled ? null : _send, ), ], ), ], ), ), ); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); _micPulseController.dispose(); _audioSubscription?.cancel(); _recorder.dispose(); _enhancer.dispose(); super.dispose(); } } // --------------------------------------------------------------------------- // Agent status bar // --------------------------------------------------------------------------- class _AgentStatusBar extends StatelessWidget { final AgentStatus status; const _AgentStatusBar({required this.status}); @override Widget build(BuildContext context) { final (String label, Color color, IconData icon) = switch (status) { AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology), AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal), AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined), _ => ('', AppColors.textMuted, Icons.info_outline), }; if (label.isEmpty) return const SizedBox.shrink(); return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: color.withOpacity(0.1), child: Row( children: [ SizedBox( width: 16, height: 16, child: status == AgentStatus.executing ? CircularProgressIndicator(strokeWidth: 2, color: color) : Icon(icon, size: 16, color: color), ), const SizedBox(width: 8), Text( label, style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), ), ], ), ); } } // --------------------------------------------------------------------------- // Standing order draft card // --------------------------------------------------------------------------- class _StandingOrderDraftCard extends StatelessWidget { final Map draft; final VoidCallback onConfirm; const _StandingOrderDraftCard({ required this.draft, required this.onConfirm, }); @override Widget build(BuildContext context) { final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令'; final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? ''; final command = draft['command'] as String? ?? draft['prompt'] as String? ?? ''; final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? []; return Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.primary.withOpacity(0.4)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Icon(Icons.schedule, size: 18, color: AppColors.primary), const SizedBox(width: 8), Expanded( child: Text( '常驻指令草案', style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.primary, ), ), ), ], ), const SizedBox(height: 10), // Name Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), if (cron.isNotEmpty) ...[ const SizedBox(height: 6), Row( children: [ const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted), const SizedBox(width: 6), Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')), ], ), ], if (command.isNotEmpty) ...[ const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(8), ), child: SelectableText( command, style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary), ), ), ], if (targets.isNotEmpty) ...[ const SizedBox(height: 6), Row( children: [ const Icon(Icons.dns, size: 14, color: AppColors.textMuted), const SizedBox(width: 6), Text( '目标: ${targets.join(", ")}', style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), ), ], ), ], const SizedBox(height: 12), // Action buttons Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton( onPressed: () { // Dismiss — no specific API call needed }, style: OutlinedButton.styleFrom( foregroundColor: AppColors.textSecondary, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), minimumSize: Size.zero, ), child: const Text('取消', style: TextStyle(fontSize: 13)), ), const SizedBox(width: 8), FilledButton( onPressed: onConfirm, style: FilledButton.styleFrom( backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), minimumSize: Size.zero, ), child: const Text('确认', style: TextStyle(fontSize: 13)), ), ], ), ], ), ); } }