it0/it0_app/lib/features/chat/presentation/pages/chat_page.dart

425 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
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()),
);
}
// -- 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),
itemCount: chatState.messages.length,
itemBuilder: (context, index) {
return _buildTimelineNode(
chatState.messages[index],
chatState,
isFirst: index == 0,
isLast: index == chatState.messages.length - 1 &&
!chatState.isStreaming,
);
},
),
),
// 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<String, dynamic> 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)),
),
],
),
],
);
}
}