import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../domain/entities/chat_message.dart'; import '../providers/chat_providers.dart'; import '../widgets/timeline_event_node.dart'; import '../widgets/stream_text_widget.dart'; import '../widgets/approval_action_card.dart'; import '../../../agent_call/presentation/pages/agent_call_page.dart'; // --------------------------------------------------------------------------- // Chat page – Timeline workflow style (inspired by Claude Code VSCode) // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); // -- 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, ); } }); } void _openVoiceCall() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AgentCallPage()), ); } /// Whether to show a virtual "working" node at the bottom of the timeline. /// True when the agent is streaming but no assistant message has appeared yet. bool _needsWorkingNode(ChatState chatState) { if (!chatState.isStreaming) return false; if (chatState.messages.isEmpty) return false; // Show working node if the last message is still the user's prompt return chatState.messages.last.role == MessageRole.user; } // -- Timeline node builder ------------------------------------------------ Widget _buildTimelineNode( ChatMessage message, ChatState chatState, { required bool isFirst, required bool isLast, }) { final isLastMessage = chatState.messages.last.id == message.id; final isStreamingNow = isLastMessage && chatState.isStreaming; // User message — special "prompt" node if (message.role == MessageRole.user) { return TimelineEventNode( status: NodeStatus.completed, label: message.content, isFirst: isFirst, isLast: isLast, icon: Icons.person_outline, ); } switch (message.type) { case MessageType.thinking: return TimelineEventNode( status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, label: '思考中...', isFirst: isFirst, isLast: isLast, content: StreamTextWidget( text: message.content, isStreaming: isStreamingNow, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13, fontStyle: FontStyle.italic, ), ), ); case MessageType.toolUse: final tool = message.toolExecution; final toolName = tool?.toolName ?? 'unknown'; final isExecuting = tool?.status == ToolStatus.executing; return TimelineEventNode( status: isExecuting ? NodeStatus.active : NodeStatus.completed, label: toolName, isFirst: isFirst, isLast: isLast, icon: isExecuting ? null : Icons.check_circle_outline, content: tool != null && tool.input.isNotEmpty ? CodeBlock(text: tool.input) : null, ); case MessageType.toolResult: final tool = message.toolExecution; final isError = tool?.status == ToolStatus.error; return TimelineEventNode( status: isError ? NodeStatus.error : NodeStatus.completed, label: isError ? '执行失败' : '执行结果', isFirst: isFirst, isLast: isLast, icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline, content: tool?.output != null && tool!.output!.isNotEmpty ? CodeBlock( text: tool.output!, textColor: isError ? AppColors.error : null, ) : null, ); case MessageType.approval: return TimelineEventNode( status: NodeStatus.warning, label: '需要审批', isFirst: isFirst, isLast: isLast, icon: Icons.shield_outlined, content: message.approvalRequest != null ? 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), ) : null, ); case MessageType.standingOrderDraft: return TimelineEventNode( status: NodeStatus.warning, label: '常驻指令草案', isFirst: isFirst, isLast: isLast, icon: Icons.schedule, content: _StandingOrderContent( draft: message.metadata ?? {}, onConfirm: () => ref.read(chatProvider.notifier) .confirmStandingOrder(message.metadata ?? {}), ), ); case MessageType.text: default: return TimelineEventNode( status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, label: isStreamingNow ? '回复中...' : '回复', isFirst: isFirst, isLast: isLast, icon: isStreamingNow ? null : Icons.check_circle_outline, content: StreamTextWidget( text: message.content, isStreaming: isStreamingNow, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, ), ), ); } } // -- 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('iAgent'), 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 call button IconButton( icon: const Icon(Icons.call), tooltip: '语音通话', onPressed: _openVoiceCall, ), ], ), body: Column( children: [ // Error banner if (chatState.error != null) Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), color: AppColors.error.withOpacity(0.1), child: Row( children: [ const Icon(Icons.error_outline, size: 16, color: AppColors.error), const SizedBox(width: 8), Expanded( child: Text( chatState.error!, style: const TextStyle(color: AppColors.error, fontSize: 13), ), ), GestureDetector( onTap: () => ref.read(chatProvider.notifier).clearChat(), child: const Icon(Icons.close, size: 16, color: AppColors.error), ), ], ), ), // Timeline message list Expanded( child: chatState.messages.isEmpty ? _buildEmptyState() : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), // Extra item for the "working" placeholder when streaming // and the last message is still a user message (no agent // response has arrived yet). itemCount: chatState.messages.length + (_needsWorkingNode(chatState) ? 1 : 0), itemBuilder: (context, index) { // Render the virtual "working" node at the end if (index == chatState.messages.length && _needsWorkingNode(chatState)) { return TimelineEventNode( status: NodeStatus.active, label: '处理中...', isFirst: false, isLast: false, ); } final isRealLast = index == chatState.messages.length - 1; return _buildTimelineNode( chatState.messages[index], chatState, isFirst: index == 0, isLast: isRealLast && !chatState.isStreaming && !_needsWorkingNode(chatState), ); }, ), ), // Input row _buildInputArea(chatState), ], ), ); } Widget _buildEmptyState() { return 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), ), const SizedBox(height: 24), OutlinedButton.icon( onPressed: _openVoiceCall, icon: const Icon(Icons.call), label: const Text('语音通话'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.success, side: const BorderSide(color: AppColors.success), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ], ), ); } 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: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: const InputDecoration( hintText: '输入指令...', 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(); super.dispose(); } } // --------------------------------------------------------------------------- // Standing order content (embedded in timeline node) // --------------------------------------------------------------------------- class _StandingOrderContent extends StatelessWidget { final Map draft; final VoidCallback onConfirm; const _StandingOrderContent({ 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 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), if (cron.isNotEmpty) ...[ const SizedBox(height: 4), Text( cron, style: const TextStyle( color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace', ), ), ], if (command.isNotEmpty) ...[ const SizedBox(height: 6), CodeBlock(text: command), ], if (targets.isNotEmpty) ...[ const SizedBox(height: 4), Text( '目标: ${targets.join(", ")}', style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), ), ], const SizedBox(height: 8), Row( children: [ OutlinedButton( onPressed: () {}, style: OutlinedButton.styleFrom( foregroundColor: AppColors.textSecondary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), minimumSize: Size.zero, ), child: const Text('取消', style: TextStyle(fontSize: 12)), ), const SizedBox(width: 8), FilledButton( onPressed: onConfirm, style: FilledButton.styleFrom( backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), minimumSize: Size.zero, ), child: const Text('确认', style: TextStyle(fontSize: 12)), ), ], ), ], ); } }