425 lines
14 KiB
Dart
425 lines
14 KiB
Dart
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)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|