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 '../widgets/conversation_drawer.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 _inject() { final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); ref.read(chatProvider.notifier).injectMessage(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: _CollapsibleThinking( text: message.content, isStreaming: isStreamingNow, ), ); 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, maxLines: 1) : 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 ? _CollapsibleCodeBlock( 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.interrupted: return TimelineEventNode( status: NodeStatus.warning, label: message.content, isFirst: isFirst, isLast: isLast, icon: Icons.cancel_outlined, ); 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( drawer: const ConversationDrawer(), appBar: AppBar( title: const Text('iAgent'), actions: [ // New chat button (always visible) IconButton( icon: const Icon(Icons.edit_outlined), tooltip: '新对话', onPressed: () => ref.read(chatProvider.notifier).startNewChat(), ), // Stop button during streaming if (chatState.isStreaming) IconButton( icon: const Icon(Icons.stop_circle_outlined), tooltip: '停止', onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ), // Voice call button IconButton( icon: const Icon(Icons.call), tooltip: '语音通话', onPressed: _openVoiceCall, ), ], ), body: SafeArea( top: false, child: 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 isAwaitingApproval = chatState.agentStatus == AgentStatus.awaitingApproval; final isStreaming = chatState.isStreaming && !isAwaitingApproval; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppColors.surface, border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), ), child: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: InputDecoration( hintText: isStreaming ? '追加指令...' : '输入指令...', border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(24)), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), textInputAction: TextInputAction.send, onSubmitted: (_) => isStreaming ? _inject() : _send(), enabled: !isAwaitingApproval, ), ), const SizedBox(width: 8), if (isStreaming) // During streaming: show both inject-send and stop buttons Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.send, color: AppColors.info), tooltip: '追加指令', onPressed: _inject, ), 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: isAwaitingApproval ? null : _send, ), ], ), ); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); super.dispose(); } } // --------------------------------------------------------------------------- // Standing order content (embedded in timeline node) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Collapsible code block for tool results – collapsed by default, tap to expand // --------------------------------------------------------------------------- class _CollapsibleCodeBlock extends StatefulWidget { final String text; final Color? textColor; const _CollapsibleCodeBlock({ required this.text, this.textColor, }); @override State<_CollapsibleCodeBlock> createState() => _CollapsibleCodeBlockState(); } class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> { bool _expanded = false; @override Widget build(BuildContext context) { final lineCount = '\n'.allMatches(widget.text).length + 1; // Short results (≤3 lines): always show fully if (lineCount <= 3) { return CodeBlock(text: widget.text, textColor: widget.textColor); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedCrossFade( firstChild: CodeBlock(text: widget.text, textColor: widget.textColor), secondChild: CodeBlock( text: widget.text, textColor: widget.textColor, maxLines: 3, ), crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), ), GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Padding( padding: const EdgeInsets.only(top: 4), child: Text( _expanded ? '收起' : '展开 ($lineCount 行)', style: TextStyle( color: AppColors.info, fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ), ], ); } } // --------------------------------------------------------------------------- // Collapsible thinking content – expanded while streaming, collapsed when done // --------------------------------------------------------------------------- class _CollapsibleThinking extends StatefulWidget { final String text; final bool isStreaming; const _CollapsibleThinking({ required this.text, required this.isStreaming, }); @override State<_CollapsibleThinking> createState() => _CollapsibleThinkingState(); } class _CollapsibleThinkingState extends State<_CollapsibleThinking> { bool _expanded = true; @override void didUpdateWidget(covariant _CollapsibleThinking oldWidget) { super.didUpdateWidget(oldWidget); // Auto-collapse when streaming ends if (oldWidget.isStreaming && !widget.isStreaming) { setState(() => _expanded = false); } } @override Widget build(BuildContext context) { // While streaming, always show expanded content if (widget.isStreaming) { return StreamTextWidget( text: widget.text, isStreaming: true, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13, fontStyle: FontStyle.italic, ), ); } // Completed – show collapsible toggle return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'Thinking', style: TextStyle( color: AppColors.textMuted, fontSize: 12, fontWeight: FontWeight.w500, ), ), const SizedBox(width: 4), AnimatedRotation( turns: _expanded ? 0.5 : 0.0, duration: const Duration(milliseconds: 200), child: Icon( Icons.expand_more, size: 16, color: AppColors.textMuted, ), ), ], ), ), AnimatedCrossFade( firstChild: Padding( padding: const EdgeInsets.only(top: 6), child: StreamTextWidget( text: widget.text, isStreaming: false, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13, fontStyle: FontStyle.italic, ), ), ), secondChild: const SizedBox.shrink(), crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), ), ], ); } } // --------------------------------------------------------------------------- // 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)), ), ], ), ], ); } }