diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index 2237b11..27d6451 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -8,193 +8,14 @@ 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/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 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? 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 { - final Ref _ref; - StreamSubscription>? _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 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; - 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: '未返回 sessionId'); - 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? ?? 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? ?? '流式传输错误', - ); - _wsSubscription?.cancel(); - } - }); - } catch (e) { - state = state.copyWith(isStreaming: false, error: e.toString()); - } - } - - void _handleStreamEvent(Map 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((ref) { - return ChatNotifier(ref); -}); +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 @@ -315,7 +136,7 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); - ref.read(chatMessagesProvider.notifier).sendMessage(text); + ref.read(chatProvider.notifier).sendMessage(text); _scrollToBottom(); } @@ -331,26 +152,150 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt }); } + // -- 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(chatMessagesProvider); + final chatState = ref.watch(chatProvider); // Auto-scroll when messages change - ref.listen(chatMessagesProvider, (_, __) => _scrollToBottom()); + 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(chatMessagesProvider.notifier).clearChat(), + onPressed: () => ref.read(chatProvider.notifier).clearChat(), ), - // Voice input button (TODO 40) + // Voice input button GestureDetector( onLongPressStart: (_) => _startListening(), onLongPressEnd: (_) => _stopListening(autoSubmit: true), @@ -388,12 +333,16 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt actions: [ TextButton( onPressed: () => - ref.read(chatMessagesProvider.notifier).clearChat(), + 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 @@ -418,13 +367,17 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - itemCount: chatState.messages.length + (chatState.isStreaming ? 1 : 0), + itemCount: chatState.messages.length + + (chatState.agentStatus == AgentStatus.thinking ? 1 : 0), itemBuilder: (context, index) { + // Trailing thinking indicator if (index == chatState.messages.length) { - // Typing indicator - return _TypingIndicator(); + return const AgentThinkingIndicator(); } - return _ChatBubble(message: chatState.messages[index]); + return _buildMessageWidget( + chatState.messages[index], + chatState, + ); }, ), ), @@ -470,50 +423,70 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt ), // 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: '向 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, - ), - ], - ), - ), - ), + _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(); @@ -527,97 +500,41 @@ class _ChatPageState extends ConsumerState with SingleTickerProviderSt } // --------------------------------------------------------------------------- -// Private helper widgets +// Agent status bar // --------------------------------------------------------------------------- -class _ChatBubble extends StatelessWidget { - final ChatMessage message; - const _ChatBubble({required this.message}); - - bool get _isUser => message.role == ChatRole.user; +class _AgentStatusBar extends StatelessWidget { + final AgentStatus status; + const _AgentStatusBar({required this.status}); @override Widget build(BuildContext context) { - if (message.contentType == ChatContentType.toolUse) { - return _ToolUseCard(content: message.content); - } + 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), + }; - 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, - ), - ), - ), - ); - } -} + if (label.isEmpty) return const SizedBox.shrink(); -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, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: color.withOpacity(0.1), + child: Row( children: [ - Row( - children: [ - Icon(Icons.code, size: 16, color: AppColors.primary), - const SizedBox(width: 6), - Text( - '工具执行', - style: TextStyle( - color: AppColors.primary, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], + SizedBox( + width: 16, + height: 16, + child: status == AgentStatus.executing + ? CircularProgressIndicator(strokeWidth: 2, color: color) + : Icon(icon, size: 16, color: color), ), - 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, - ), - ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), ), ], ), @@ -625,74 +542,132 @@ class _ToolUseCard extends StatelessWidget { } } -class _TypingIndicator extends StatefulWidget { - @override - State<_TypingIndicator> createState() => _TypingIndicatorState(); -} +// --------------------------------------------------------------------------- +// Standing order draft card +// --------------------------------------------------------------------------- -class _TypingIndicatorState extends State<_TypingIndicator> - with SingleTickerProviderStateMixin { - late AnimationController _controller; +class _StandingOrderDraftCard extends StatelessWidget { + final Map draft; + final VoidCallback onConfirm; - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - )..repeat(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + const _StandingOrderDraftCard({ + required this.draft, + required this.onConfirm, + }); @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, - ), - ), + 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)), + ), + ], + ), + ], ), ); } } - -/// 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.)