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

522 lines
18 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/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';
import '../../../agent_call/presentation/pages/agent_call_page.dart';
// ---------------------------------------------------------------------------
// Chat page ConsumerStatefulWidget
// ---------------------------------------------------------------------------
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()),
);
}
// -- 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(chatProvider);
// Auto-scroll when messages change
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(chatProvider.notifier).clearChat(),
),
// Voice call button
IconButton(
icon: const Icon(Icons.call),
tooltip: '语音通话',
onPressed: _openVoiceCall,
),
],
),
body: Column(
children: [
// Error banner
if (chatState.error != null)
MaterialBanner(
content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)),
backgroundColor: AppColors.error.withOpacity(0.1),
actions: [
TextButton(
onPressed: () =>
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
? 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),
),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
itemCount: chatState.messages.length +
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
itemBuilder: (context, index) {
// Trailing thinking indicator
if (index == chatState.messages.length) {
return const AgentThinkingIndicator();
}
return _buildMessageWidget(
chatState.messages[index],
chatState,
);
},
),
),
// Input row
_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();
_scrollController.dispose();
super.dispose();
}
}
// ---------------------------------------------------------------------------
// Agent status bar
// ---------------------------------------------------------------------------
class _AgentStatusBar extends StatelessWidget {
final AgentStatus status;
const _AgentStatusBar({required this.status});
@override
Widget build(BuildContext context) {
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),
};
if (label.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: color.withOpacity(0.1),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: status == AgentStatus.executing
? CircularProgressIndicator(strokeWidth: 2, color: color)
: Icon(icon, size: 16, color: color),
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Standing order draft card
// ---------------------------------------------------------------------------
class _StandingOrderDraftCard extends StatelessWidget {
final Map<String, dynamic> draft;
final VoidCallback onConfirm;
const _StandingOrderDraftCard({
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 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)),
),
],
),
],
),
);
}
}